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