at 24.11-pre 40 kB view raw
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 nonexistent 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 nonexistent 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 nonexistent 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 nonexistent 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 Elixirs 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}