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