diff options
Diffstat (limited to 'nixpkgs/nixos/modules/services/web-apps/akkoma.nix')
-rw-r--r-- | nixpkgs/nixos/modules/services/web-apps/akkoma.nix | 1086 |
1 files changed, 1086 insertions, 0 deletions
diff --git a/nixpkgs/nixos/modules/services/web-apps/akkoma.nix b/nixpkgs/nixos/modules/services/web-apps/akkoma.nix new file mode 100644 index 000000000000..8d1775258612 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/akkoma.nix @@ -0,0 +1,1086 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.akkoma; + ex = cfg.config; + db = ex.":pleroma"."Pleroma.Repo"; + web = ex.":pleroma"."Pleroma.Web.Endpoint"; + + isConfined = config.systemd.services.akkoma.confinement.enable; + hasSmtp = (attrByPath [ ":pleroma" "Pleroma.Emails.Mailer" "adapter" "value" ] null ex) == "Swoosh.Adapters.SMTP"; + + isAbsolutePath = v: isString v && substring 0 1 v == "/"; + isSecret = v: isAttrs v && v ? _secret && isAbsolutePath v._secret; + + absolutePath = with types; mkOptionType { + name = "absolutePath"; + description = "absolute path"; + descriptionClass = "noun"; + check = isAbsolutePath; + inherit (str) merge; + }; + + secret = mkOptionType { + name = "secret"; + description = "secret value"; + descriptionClass = "noun"; + check = isSecret; + nestedTypes = { + _secret = absolutePath; + }; + }; + + ipAddress = with types; mkOptionType { + name = "ipAddress"; + description = "IPv4 or IPv6 address"; + descriptionClass = "conjunction"; + check = x: str.check x && builtins.match "[.0-9:A-Fa-f]+" x != null; + inherit (str) merge; + }; + + elixirValue = let + elixirValue' = with types; + nullOr (oneOf [ bool int float str (attrsOf elixirValue') (listOf elixirValue') ]) // { + description = "Elixir value"; + }; + in elixirValue'; + + frontend = { + options = { + package = mkOption { + type = types.package; + description = mdDoc "Akkoma frontend package."; + example = literalExpression "pkgs.akkoma-frontends.akkoma-fe"; + }; + + name = mkOption { + type = types.nonEmptyStr; + description = mdDoc "Akkoma frontend name."; + example = "akkoma-fe"; + }; + + ref = mkOption { + type = types.nonEmptyStr; + description = mdDoc "Akkoma frontend reference."; + example = "stable"; + }; + }; + }; + + sha256 = builtins.hashString "sha256"; + + replaceSec = let + replaceSec' = { }@args: v: + if isAttrs v + then if v ? _secret + then if isAbsolutePath v._secret + then sha256 v._secret + else abort "Invalid secret path (_secret = ${v._secret})" + else mapAttrs (_: val: replaceSec' args val) v + else if isList v + then map (replaceSec' args) v + else v; + in replaceSec' { }; + + # Erlang/Elixir uses a somewhat special format for IP addresses + erlAddr = addr: fileContents + (pkgs.runCommand addr { + nativeBuildInputs = with pkgs; [ elixir ]; + code = '' + case :inet.parse_address('${addr}') do + {:ok, addr} -> IO.inspect addr + {:error, _} -> System.halt(65) + end + ''; + passAsFile = [ "code" ]; + } ''elixir "$codePath" >"$out"''); + + format = pkgs.formats.elixirConf { }; + configFile = format.generate "config.exs" + (replaceSec + (attrsets.updateManyAttrsByPath [{ + path = [ ":pleroma" "Pleroma.Web.Endpoint" "http" "ip" ]; + update = addr: + if isAbsolutePath addr + then format.lib.mkTuple + [ (format.lib.mkAtom ":local") addr ] + else format.lib.mkRaw (erlAddr addr); + }] cfg.config)); + + writeShell = { name, text, runtimeInputs ? [ ] }: + pkgs.writeShellApplication { inherit name text runtimeInputs; } + "/bin/${name}"; + + genScript = writeShell { + name = "akkoma-gen-cookie"; + runtimeInputs = with pkgs; [ coreutils util-linux ]; + text = '' + install -m 0400 \ + -o ${escapeShellArg cfg.user } \ + -g ${escapeShellArg cfg.group} \ + <(hexdump -n 16 -e '"%02x"' /dev/urandom) \ + "$RUNTIME_DIRECTORY/cookie" + ''; + }; + + copyScript = writeShell { + name = "akkoma-copy-cookie"; + runtimeInputs = with pkgs; [ coreutils ]; + text = '' + install -m 0400 \ + -o ${escapeShellArg cfg.user} \ + -g ${escapeShellArg cfg.group} \ + ${escapeShellArg cfg.dist.cookie._secret} \ + "$RUNTIME_DIRECTORY/cookie" + ''; + }; + + secretPaths = catAttrs "_secret" (collect isSecret cfg.config); + + vapidKeygen = pkgs.writeText "vapidKeygen.exs" '' + [public_path, private_path] = System.argv() + {public_key, private_key} = :crypto.generate_key :ecdh, :prime256v1 + File.write! public_path, Base.url_encode64(public_key, padding: false) + File.write! private_path, Base.url_encode64(private_key, padding: false) + ''; + + initSecretsScript = writeShell { + name = "akkoma-init-secrets"; + runtimeInputs = with pkgs; [ coreutils elixir ]; + text = let + key-base = web.secret_key_base; + jwt-signer = ex.":joken".":default_signer"; + signing-salt = web.signing_salt; + liveview-salt = web.live_view.signing_salt; + vapid-private = ex.":web_push_encryption".":vapid_details".private_key; + vapid-public = ex.":web_push_encryption".":vapid_details".public_key; + in '' + secret() { + # Generate default secret if non‐existent + test -e "$2" || install -D -m 0600 <(tr -dc 'A-Za-z-._~' </dev/urandom | head -c "$1") "$2" + if [ "$(stat --dereference --format='%s' "$2")" -lt "$1" ]; then + echo "Secret '$2' is smaller than minimum size of $1 bytes." >&2 + exit 65 + fi + } + + secret 64 ${escapeShellArg key-base._secret} + secret 64 ${escapeShellArg jwt-signer._secret} + secret 8 ${escapeShellArg signing-salt._secret} + secret 8 ${escapeShellArg liveview-salt._secret} + + ${optionalString (isSecret vapid-public) '' + { test -e ${escapeShellArg vapid-private._secret} && \ + test -e ${escapeShellArg vapid-public._secret}; } || \ + elixir ${escapeShellArgs [ vapidKeygen vapid-public._secret vapid-private._secret ]} + ''} + ''; + }; + + configScript = writeShell { + name = "akkoma-config"; + runtimeInputs = with pkgs; [ coreutils replace-secret ]; + text = '' + cd "$RUNTIME_DIRECTORY" + tmp="$(mktemp config.exs.XXXXXXXXXX)" + trap 'rm -f "$tmp"' EXIT TERM + + cat ${escapeShellArg configFile} >"$tmp" + ${concatMapStrings (file: '' + replace-secret ${escapeShellArgs [ (sha256 file) file ]} "$tmp" + '') secretPaths} + + chown ${escapeShellArg cfg.user}:${escapeShellArg cfg.group} "$tmp" + chmod 0400 "$tmp" + mv -f "$tmp" config.exs + ''; + }; + + pgpass = let + esc = escape [ ":" ''\'' ]; + in if (cfg.initDb.password != null) + then pkgs.writeText "pgpass.conf" '' + *:*:*${esc cfg.initDb.username}:${esc (sha256 cfg.initDb.password._secret)} + '' + else null; + + escapeSqlId = x: ''"${replaceStrings [ ''"'' ] [ ''""'' ] x}"''; + escapeSqlStr = x: "'${replaceStrings [ "'" ] [ "''" ] x}'"; + + setupSql = pkgs.writeText "setup.psql" '' + \set ON_ERROR_STOP on + + ALTER ROLE ${escapeSqlId db.username} + LOGIN PASSWORD ${if db ? password + then "${escapeSqlStr (sha256 db.password._secret)}" + else "NULL"}; + + ALTER DATABASE ${escapeSqlId db.database} + OWNER TO ${escapeSqlId db.username}; + + \connect ${escapeSqlId db.database} + CREATE EXTENSION IF NOT EXISTS citext; + CREATE EXTENSION IF NOT EXISTS pg_trgm; + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + ''; + + dbHost = if db ? socket_dir then db.socket_dir + else if db ? socket then db.socket + else if db ? hostname then db.hostname + else null; + + initDbScript = writeShell { + name = "akkoma-initdb"; + runtimeInputs = with pkgs; [ coreutils replace-secret config.services.postgresql.package ]; + text = '' + pgpass="$(mktemp -t pgpass-XXXXXXXXXX.conf)" + setupSql="$(mktemp -t setup-XXXXXXXXXX.psql)" + trap 'rm -f "$pgpass $setupSql"' EXIT TERM + + ${optionalString (dbHost != null) '' + export PGHOST=${escapeShellArg dbHost} + ''} + export PGUSER=${escapeShellArg cfg.initDb.username} + ${optionalString (pgpass != null) '' + cat ${escapeShellArg pgpass} >"$pgpass" + replace-secret ${escapeShellArgs [ + (sha256 cfg.initDb.password._secret) cfg.initDb.password._secret ]} "$pgpass" + export PGPASSFILE="$pgpass" + ''} + + cat ${escapeShellArg setupSql} >"$setupSql" + ${optionalString (db ? password) '' + replace-secret ${escapeShellArgs [ + (sha256 db.password._secret) db.password._secret ]} "$setupSql" + ''} + + # Create role if non‐existent + psql -tAc "SELECT 1 FROM pg_roles + WHERE rolname = "${escapeShellArg (escapeSqlStr db.username)} | grep -F -q 1 || \ + psql -tAc "CREATE ROLE "${escapeShellArg (escapeSqlId db.username)} + + # Create database if non‐existent + psql -tAc "SELECT 1 FROM pg_database + WHERE datname = "${escapeShellArg (escapeSqlStr db.database)} | grep -F -q 1 || \ + psql -tAc "CREATE DATABASE "${escapeShellArg (escapeSqlId db.database)}" + OWNER "${escapeShellArg (escapeSqlId db.username)}" + TEMPLATE template0 + ENCODING 'utf8' + LOCALE 'C'" + + psql -f "$setupSql" + ''; + }; + + envWrapper = let + script = writeShell { + name = "akkoma-env"; + text = '' + cd "${cfg.package}" + + RUNTIME_DIRECTORY="''${RUNTIME_DIRECTORY:-/run/akkoma}" + AKKOMA_CONFIG_PATH="$RUNTIME_DIRECTORY/config.exs" \ + ERL_EPMD_ADDRESS="${cfg.dist.address}" \ + ERL_EPMD_PORT="${toString cfg.dist.epmdPort}" \ + ERL_FLAGS="${concatStringsSep " " [ + "-kernel inet_dist_use_interface '${erlAddr cfg.dist.address}'" + "-kernel inet_dist_listen_min ${toString cfg.dist.portMin}" + "-kernel inet_dist_listen_max ${toString cfg.dist.portMax}" + ]}" \ + RELEASE_COOKIE="$(<"$RUNTIME_DIRECTORY/cookie")" \ + RELEASE_NAME="akkoma" \ + exec "${cfg.package}/bin/$(basename "$0")" "$@" + ''; + }; + in pkgs.runCommandLocal "akkoma-env" { } '' + mkdir -p "$out/bin" + + ln -r -s ${escapeShellArg script} "$out/bin/pleroma" + ln -r -s ${escapeShellArg script} "$out/bin/pleroma_ctl" + ''; + + userWrapper = pkgs.writeShellApplication { + name = "pleroma_ctl"; + text = '' + if [ "''${1-}" == "update" ]; then + echo "OTP releases are not supported on NixOS." >&2 + exit 64 + fi + + exec sudo -u ${escapeShellArg cfg.user} \ + "${envWrapper}/bin/pleroma_ctl" "$@" + ''; + }; + + socketScript = if isAbsolutePath web.http.ip + then writeShell { + name = "akkoma-socket"; + runtimeInputs = with pkgs; [ coreutils inotify-tools ]; + text = '' + coproc { + inotifywait -q -m -e create ${escapeShellArg (dirOf web.http.ip)} + } + + trap 'kill "$COPROC_PID"' EXIT TERM + + until test -S ${escapeShellArg web.http.ip} + do read -r -u "''${COPROC[0]}" + done + + chmod 0666 ${escapeShellArg web.http.ip} + ''; + } + else null; + + staticDir = ex.":pleroma".":instance".static_dir; + uploadDir = ex.":pleroma".":instance".upload_dir; + + staticFiles = pkgs.runCommandLocal "akkoma-static" { } '' + ${concatStringsSep "\n" (mapAttrsToList (key: val: '' + mkdir -p $out/frontends/${escapeShellArg val.name}/ + ln -s ${escapeShellArg val.package} $out/frontends/${escapeShellArg val.name}/${escapeShellArg val.ref} + '') cfg.frontends)} + + ${optionalString (cfg.extraStatic != null) + (concatStringsSep "\n" (mapAttrsToList (key: val: '' + mkdir -p "$out/$(dirname ${escapeShellArg key})" + ln -s ${escapeShellArg val} $out/${escapeShellArg key} + '') cfg.extraStatic))} + ''; +in { + options = { + services.akkoma = { + enable = mkEnableOption (mdDoc "Akkoma"); + + package = mkOption { + type = types.package; + default = pkgs.akkoma; + defaultText = literalExpression "pkgs.akkoma"; + description = mdDoc "Akkoma package to use."; + }; + + user = mkOption { + type = types.nonEmptyStr; + default = "akkoma"; + description = mdDoc "User account under which Akkoma runs."; + }; + + group = mkOption { + type = types.nonEmptyStr; + default = "akkoma"; + description = mdDoc "Group account under which Akkoma runs."; + }; + + initDb = { + enable = mkOption { + type = types.bool; + default = true; + description = mdDoc '' + Whether to automatically initialise the database on startup. This will create a + database role and database if they do not already exist, and (re)set the role password + and the ownership of the database. + + This setting can be used safely even if the database already exists and contains data. + + The database settings are configured through + [{option}`config.services.akkoma.config.":pleroma"."Pleroma.Repo"`](#opt-services.akkoma.config.__pleroma_._Pleroma.Repo_). + + If disabled, the database has to be set up manually: + + ```SQL + CREATE ROLE akkoma LOGIN; + + CREATE DATABASE akkoma + OWNER akkoma + TEMPLATE template0 + ENCODING 'utf8' + LOCALE 'C'; + + \connect akkoma + CREATE EXTENSION IF NOT EXISTS citext; + CREATE EXTENSION IF NOT EXISTS pg_trgm; + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + ``` + ''; + }; + + username = mkOption { + type = types.nonEmptyStr; + default = config.services.postgresql.superUser; + defaultText = literalExpression "config.services.postgresql.superUser"; + description = mdDoc '' + Name of the database user to initialise the database with. + + This user is required to have the `CREATEROLE` and `CREATEDB` capabilities. + ''; + }; + + password = mkOption { + type = types.nullOr secret; + default = null; + description = mdDoc '' + Password of the database user to initialise the database with. + + If set to `null`, no password will be used. + + The attribute `_secret` should point to a file containing the secret. + ''; + }; + }; + + initSecrets = mkOption { + type = types.bool; + default = true; + description = mdDoc '' + Whether to initialise non‐existent secrets with random values. + + If enabled, appropriate secrets for the following options will be created automatically + if the files referenced in the `_secrets` attribute do not exist during startup. + + - {option}`config.":pleroma"."Pleroma.Web.Endpoint".secret_key_base` + - {option}`config.":pleroma"."Pleroma.Web.Endpoint".signing_salt` + - {option}`config.":pleroma"."Pleroma.Web.Endpoint".live_view.signing_salt` + - {option}`config.":web_push_encryption".":vapid_details".private_key` + - {option}`config.":web_push_encryption".":vapid_details".public_key` + - {option}`config.":joken".":default_signer"` + ''; + }; + + installWrapper = mkOption { + type = types.bool; + default = true; + description = mdDoc '' + Whether to install a wrapper around `pleroma_ctl` to simplify administration of the + Akkoma instance. + ''; + }; + + extraPackages = mkOption { + type = with types; listOf package; + default = with pkgs; [ exiftool ffmpeg_5-headless graphicsmagick-imagemagick-compat ]; + defaultText = literalExpression "with pkgs; [ exiftool graphicsmagick-imagemagick-compat ffmpeg_5-headless ]"; + example = literalExpression "with pkgs; [ exiftool imagemagick ffmpeg_5-full ]"; + description = mdDoc '' + List of extra packages to include in the executable search path of the service unit. + These are needed by various configurable components such as: + + - ExifTool for the `Pleroma.Upload.Filter.Exiftool` upload filter, + - ImageMagick for still image previews in the media proxy as well as for the + `Pleroma.Upload.Filters.Mogrify` upload filter, and + - ffmpeg for video previews in the media proxy. + ''; + }; + + frontends = mkOption { + description = mdDoc "Akkoma frontends."; + type = with types; attrsOf (submodule frontend); + default = { + primary = { + package = pkgs.akkoma-frontends.akkoma-fe; + name = "akkoma-fe"; + ref = "stable"; + }; + admin = { + package = pkgs.akkoma-frontends.admin-fe; + name = "admin-fe"; + ref = "stable"; + }; + }; + defaultText = literalExpression '' + { + primary = { + package = pkgs.akkoma-frontends.akkoma-fe; + name = "akkoma-fe"; + ref = "stable"; + }; + admin = { + package = pkgs.akkoma-frontends.admin-fe; + name = "admin-fe"; + ref = "stable"; + }; + } + ''; + }; + + extraStatic = mkOption { + type = with types; nullOr (attrsOf package); + description = mdDoc '' + Attribute set of extra packages to add to the static files directory. + + Do not add frontends here. These should be configured through + [{option}`services.akkoma.frontends`](#opt-services.akkoma.frontends). + ''; + default = null; + example = literalExpression '' + { + "emoji/blobs.gg" = pkgs.akkoma-emoji.blobs_gg; + "static/terms-of-service.html" = pkgs.writeText "terms-of-service.html" ''' + … + '''; + "favicon.png" = let + rev = "697a8211b0f427a921e7935a35d14bb3e32d0a2c"; + in pkgs.stdenvNoCC.mkDerivation { + name = "favicon.png"; + + src = pkgs.fetchurl { + url = "https://raw.githubusercontent.com/TilCreator/NixOwO/''${rev}/NixOwO_plain.svg"; + hash = "sha256-tWhHMfJ3Od58N9H5yOKPMfM56hYWSOnr/TGCBi8bo9E="; + }; + + nativeBuildInputs = with pkgs; [ librsvg ]; + + dontUnpack = true; + installPhase = ''' + rsvg-convert -o $out -w 96 -h 96 $src + '''; + }; + } + ''; + }; + + dist = { + address = mkOption { + type = ipAddress; + default = "127.0.0.1"; + description = mdDoc '' + Listen address for Erlang distribution protocol and Port Mapper Daemon (epmd). + ''; + }; + + epmdPort = mkOption { + type = types.port; + default = 4369; + description = mdDoc "TCP port to bind Erlang Port Mapper Daemon to."; + }; + + portMin = mkOption { + type = types.port; + default = 49152; + description = mdDoc "Lower bound for Erlang distribution protocol TCP port."; + }; + + portMax = mkOption { + type = types.port; + default = 65535; + description = mdDoc "Upper bound for Erlang distribution protocol TCP port."; + }; + + cookie = mkOption { + type = types.nullOr secret; + default = null; + example = { _secret = "/var/lib/secrets/akkoma/releaseCookie"; }; + description = mdDoc '' + Erlang release cookie. + + If set to `null`, a temporary random cookie will be generated. + ''; + }; + }; + + config = mkOption { + description = mdDoc '' + Configuration for Akkoma. The attributes are serialised to Elixir DSL. + + Refer to <https://docs.akkoma.dev/stable/configuration/cheatsheet/> for + configuration options. + + Settings containing secret data should be set to an attribute set containing the + attribute `_secret` - a string pointing to a file containing the value the option + should be set to. + ''; + type = types.submodule { + freeformType = format.type; + options = { + ":pleroma" = { + ":instance" = { + name = mkOption { + type = types.nonEmptyStr; + description = mdDoc "Instance name."; + }; + + email = mkOption { + type = types.nonEmptyStr; + description = mdDoc "Instance administrator email."; + }; + + description = mkOption { + type = types.nonEmptyStr; + description = mdDoc "Instance description."; + }; + + static_dir = mkOption { + type = types.path; + default = toString staticFiles; + defaultText = literalMD '' + Derivation gathering the following paths into a directory: + + - [{option}`services.akkoma.frontends`](#opt-services.akkoma.frontends) + - [{option}`services.akkoma.extraStatic`](#opt-services.akkoma.extraStatic) + ''; + description = mdDoc '' + Directory of static files. + + This directory can be built using a derivation, or it can be managed as mutable + state by setting the option to an absolute path. + ''; + }; + + upload_dir = mkOption { + type = absolutePath; + default = "/var/lib/akkoma/uploads"; + description = mdDoc '' + Directory where Akkoma will put uploaded files. + ''; + }; + }; + + "Pleroma.Repo" = mkOption { + type = elixirValue; + default = { + adapter = format.lib.mkRaw "Ecto.Adapters.Postgres"; + socket_dir = "/run/postgresql"; + username = cfg.user; + database = "akkoma"; + }; + defaultText = literalExpression '' + { + adapter = (pkgs.formats.elixirConf { }).lib.mkRaw "Ecto.Adapters.Postgres"; + socket_dir = "/run/postgresql"; + username = config.services.akkoma.user; + database = "akkoma"; + } + ''; + description = mdDoc '' + Database configuration. + + Refer to + <https://hexdocs.pm/ecto_sql/Ecto.Adapters.Postgres.html#module-connection-options> + for options. + ''; + }; + + "Pleroma.Web.Endpoint" = { + url = { + host = mkOption { + type = types.nonEmptyStr; + default = config.networking.fqdn; + defaultText = literalExpression "config.networking.fqdn"; + description = mdDoc "Domain name of the instance."; + }; + + scheme = mkOption { + type = types.nonEmptyStr; + default = "https"; + description = mdDoc "URL scheme."; + }; + + port = mkOption { + type = types.port; + default = 443; + description = mdDoc "External port number."; + }; + }; + + http = { + ip = mkOption { + type = types.either absolutePath ipAddress; + default = "/run/akkoma/socket"; + example = "::1"; + description = mdDoc '' + Listener IP address or Unix socket path. + + The value is automatically converted to Elixir’s internal address + representation during serialisation. + ''; + }; + + port = mkOption { + type = types.port; + default = if isAbsolutePath web.http.ip then 0 else 4000; + defaultText = literalExpression '' + if isAbsolutePath config.services.akkoma.config.:pleroma"."Pleroma.Web.Endpoint".http.ip + then 0 + else 4000; + ''; + description = mdDoc '' + Listener port number. + + Must be 0 if using a Unix socket. + ''; + }; + }; + + secret_key_base = mkOption { + type = secret; + default = { _secret = "/var/lib/secrets/akkoma/key-base"; }; + description = mdDoc '' + Secret key used as a base to generate further secrets for encrypting and + signing data. + + The attribute `_secret` should point to a file containing the secret. + + This key can generated can be generated as follows: + + ```ShellSession + $ tr -dc 'A-Za-z-._~' </dev/urandom | head -c 64 + ``` + ''; + }; + + live_view = { + signing_salt = mkOption { + type = secret; + default = { _secret = "/var/lib/secrets/akkoma/liveview-salt"; }; + description = mdDoc '' + LiveView signing salt. + + The attribute `_secret` should point to a file containing the secret. + + This salt can be generated as follows: + + ```ShellSession + $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 8 + ``` + ''; + }; + }; + + signing_salt = mkOption { + type = secret; + default = { _secret = "/var/lib/secrets/akkoma/signing-salt"; }; + description = mdDoc '' + Signing salt. + + The attribute `_secret` should point to a file containing the secret. + + This salt can be generated as follows: + + ```ShellSession + $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 8 + ``` + ''; + }; + }; + + ":frontends" = mkOption { + type = elixirValue; + default = mapAttrs + (key: val: format.lib.mkMap { name = val.name; ref = val.ref; }) + cfg.frontends; + defaultText = literalExpression '' + lib.mapAttrs (key: val: + (pkgs.formats.elixirConf { }).lib.mkMap { name = val.name; ref = val.ref; }) + config.services.akkoma.frontends; + ''; + description = mdDoc '' + Frontend configuration. + + Users should rely on the default value and prefer to configure frontends through + [{option}`config.services.akkoma.frontends`](#opt-services.akkoma.frontends). + ''; + }; + }; + + ":web_push_encryption" = mkOption { + default = { }; + description = mdDoc '' + Web Push Notifications configuration. + + The necessary key pair can be generated as follows: + + ```ShellSession + $ nix-shell -p nodejs --run 'npx web-push generate-vapid-keys' + ``` + ''; + type = types.submodule { + freeformType = elixirValue; + options = { + ":vapid_details" = { + subject = mkOption { + type = types.nonEmptyStr; + default = "mailto:${ex.":pleroma".":instance".email}"; + defaultText = literalExpression '' + "mailto:''${config.services.akkoma.config.":pleroma".":instance".email}" + ''; + description = mdDoc "mailto URI for administrative contact."; + }; + + public_key = mkOption { + type = with types; either nonEmptyStr secret; + default = { _secret = "/var/lib/secrets/akkoma/vapid-public"; }; + description = mdDoc "base64-encoded public ECDH key."; + }; + + private_key = mkOption { + type = secret; + default = { _secret = "/var/lib/secrets/akkoma/vapid-private"; }; + description = mdDoc '' + base64-encoded private ECDH key. + + The attribute `_secret` should point to a file containing the secret. + ''; + }; + }; + }; + }; + }; + + ":joken" = { + ":default_signer" = mkOption { + type = secret; + default = { _secret = "/var/lib/secrets/akkoma/jwt-signer"; }; + description = mdDoc '' + JWT signing secret. + + The attribute `_secret` should point to a file containing the secret. + + This secret can be generated as follows: + + ```ShellSession + $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 64 + ``` + ''; + }; + }; + + ":logger" = { + ":backends" = mkOption { + type = types.listOf elixirValue; + visible = false; + default = with format.lib; [ + (mkTuple [ (mkRaw "ExSyslogger") (mkAtom ":ex_syslogger") ]) + ]; + }; + + ":ex_syslogger" = { + ident = mkOption { + type = types.str; + visible = false; + default = "akkoma"; + }; + + level = mkOption { + type = types.nonEmptyStr; + apply = format.lib.mkAtom; + default = ":info"; + example = ":warning"; + description = mdDoc '' + Log level. + + Refer to + <https://hexdocs.pm/logger/Logger.html#module-levels> + for options. + ''; + }; + }; + }; + + ":tzdata" = { + ":data_dir" = mkOption { + type = elixirValue; + internal = true; + default = format.lib.mkRaw '' + Path.join(System.fetch_env!("CACHE_DIRECTORY"), "tzdata") + ''; + }; + }; + }; + }; + }; + + nginx = mkOption { + type = with types; nullOr (submodule + (import ../web-servers/nginx/vhost-options.nix { inherit config lib; })); + default = null; + description = mdDoc '' + Extra configuration for the nginx virtual host of Akkoma. + + If set to `null`, no virtual host will be added to the nginx configuration. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + warnings = optionals (!config.security.sudo.enable) ['' + The pleroma_ctl wrapper enabled by the installWrapper option relies on + sudo, which appears to have been disabled through security.sudo.enable. + '']; + + users = { + users."${cfg.user}" = { + description = "Akkoma user"; + group = cfg.group; + isSystemUser = true; + }; + groups."${cfg.group}" = { }; + }; + + # Confinement of the main service unit requires separation of the + # configuration generation into a separate unit to permit access to secrets + # residing outside of the chroot. + systemd.services.akkoma-config = { + description = "Akkoma social network configuration"; + reloadTriggers = [ configFile ] ++ secretPaths; + + unitConfig.PropagatesReloadTo = [ "akkoma.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + UMask = "0077"; + + RuntimeDirectory = "akkoma"; + + ExecStart = mkMerge [ + (mkIf (cfg.dist.cookie == null) [ genScript ]) + (mkIf (cfg.dist.cookie != null) [ copyScript ]) + (mkIf cfg.initSecrets [ initSecretsScript ]) + [ configScript ] + ]; + + ExecReload = mkMerge [ + (mkIf cfg.initSecrets [ initSecretsScript ]) + [ configScript ] + ]; + }; + }; + + systemd.services.akkoma-initdb = mkIf cfg.initDb.enable { + description = "Akkoma social network database setup"; + requires = [ "akkoma-config.service" ]; + requiredBy = [ "akkoma.service" ]; + after = [ "akkoma-config.service" "postgresql.service" ]; + before = [ "akkoma.service" ]; + + serviceConfig = { + Type = "oneshot"; + User = mkIf (db ? socket_dir || db ? socket) + cfg.initDb.username; + RemainAfterExit = true; + UMask = "0077"; + ExecStart = initDbScript; + PrivateTmp = true; + }; + }; + + systemd.services.akkoma = let + runtimeInputs = with pkgs; [ coreutils gawk gnused ] ++ cfg.extraPackages; + in { + description = "Akkoma social network"; + documentation = [ "https://docs.akkoma.dev/stable/" ]; + + # This service depends on network-online.target and is sequenced after + # it because it requires access to the Internet to function properly. + bindsTo = [ "akkoma-config.service" ]; + wants = [ "network-online.service" ]; + wantedBy = [ "multi-user.target" ]; + after = [ + "akkoma-config.target" + "network.target" + "network-online.target" + "postgresql.service" + ]; + + confinement.packages = mkIf isConfined runtimeInputs; + path = runtimeInputs; + + serviceConfig = { + Type = "exec"; + User = cfg.user; + Group = cfg.group; + UMask = "0077"; + + # The run‐time directory is preserved as it is managed by the akkoma-config.service unit. + RuntimeDirectory = "akkoma"; + RuntimeDirectoryPreserve = true; + + CacheDirectory = "akkoma"; + + BindPaths = [ "${uploadDir}:${uploadDir}:norbind" ]; + BindReadOnlyPaths = mkMerge [ + (mkIf (!isStorePath staticDir) [ "${staticDir}:${staticDir}:norbind" ]) + (mkIf isConfined (mkMerge [ + [ "/etc/hosts" "/etc/resolv.conf" ] + (mkIf (isStorePath staticDir) (map (dir: "${dir}:${dir}:norbind") + (splitString "\n" (readFile ((pkgs.closureInfo { rootPaths = staticDir; }) + "/store-paths"))))) + (mkIf (db ? socket_dir) [ "${db.socket_dir}:${db.socket_dir}:norbind" ]) + (mkIf (db ? socket) [ "${db.socket}:${db.socket}:norbind" ]) + ])) + ]; + + ExecStartPre = "${envWrapper}/bin/pleroma_ctl migrate"; + ExecStart = "${envWrapper}/bin/pleroma start"; + ExecStartPost = socketScript; + ExecStop = "${envWrapper}/bin/pleroma stop"; + ExecStopPost = mkIf (isAbsolutePath web.http.ip) + "${pkgs.coreutils}/bin/rm -f '${web.http.ip}'"; + + ProtectProc = "noaccess"; + ProcSubset = "pid"; + ProtectSystem = mkIf (!isConfined) "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + PrivateIPC = true; + ProtectHostname = true; + ProtectClock = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + LockPersonality = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RemoveIPC = true; + + CapabilityBoundingSet = mkIf + (any (port: port > 0 && port < 1024) + [ web.http.port cfg.dist.epmdPort cfg.dist.portMin ]) + [ "CAP_NET_BIND_SERVICE" ]; + + NoNewPrivileges = true; + SystemCallFilter = [ "@system-service" "~@privileged" "@chown" ]; + SystemCallArchitectures = "native"; + + DeviceAllow = null; + DevicePolicy = "closed"; + + # SMTP adapter uses dynamic port 0 binding, which is incompatible with bind address filtering + SocketBindAllow = mkIf (!hasSmtp) (mkMerge [ + [ "tcp:${toString cfg.dist.epmdPort}" "tcp:${toString cfg.dist.portMin}-${toString cfg.dist.portMax}" ] + (mkIf (web.http.port != 0) [ "tcp:${toString web.http.port}" ]) + ]); + SocketBindDeny = mkIf (!hasSmtp) "any"; + }; + }; + + systemd.tmpfiles.rules = [ + "d ${uploadDir} 0700 ${cfg.user} ${cfg.group} - -" + "Z ${uploadDir} ~0700 ${cfg.user} ${cfg.group} - -" + ]; + + environment.systemPackages = mkIf (cfg.installWrapper) [ userWrapper ]; + + services.nginx.virtualHosts = mkIf (cfg.nginx != null) { + ${web.url.host} = mkMerge [ cfg.nginx { + locations."/" = { + proxyPass = + if isAbsolutePath web.http.ip + then "http://unix:${web.http.ip}" + else if hasInfix ":" web.http.ip + then "http://[${web.http.ip}]:${toString web.http.port}" + else "http://${web.http.ip}:${toString web.http.port}"; + + proxyWebsockets = true; + recommendedProxySettings = true; + }; + }]; + }; + }; + + meta.maintainers = with maintainers; [ mvs ]; + meta.doc = ./akkoma.md; +} |