at 23.05-pre 38 kB view raw
1{ config, options, lib, pkgs, utils, ... }: 2 3let 4 json = pkgs.formats.json {}; 5 6 cfg = config.services.discourse; 7 opt = options.services.discourse; 8 9 # Keep in sync with https://github.com/discourse/discourse_docker/blob/main/image/base/slim.Dockerfile#L5 10 upstreamPostgresqlVersion = lib.getVersion pkgs.postgresql_13; 11 12 postgresqlPackage = if config.services.postgresql.enable then 13 config.services.postgresql.package 14 else 15 pkgs.postgresql; 16 17 postgresqlVersion = lib.getVersion postgresqlPackage; 18 19 # We only want to create a database if we're actually going to connect to it. 20 databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == null; 21 22 tlsEnabled = (cfg.enableACME 23 || cfg.sslCertificate != null 24 || cfg.sslCertificateKey != null); 25in 26{ 27 options = { 28 services.discourse = { 29 enable = lib.mkEnableOption (lib.mdDoc "Discourse, an open source discussion platform"); 30 31 package = lib.mkOption { 32 type = lib.types.package; 33 default = pkgs.discourse; 34 apply = p: p.override { 35 plugins = lib.unique (p.enabledPlugins ++ cfg.plugins); 36 }; 37 defaultText = lib.literalExpression "pkgs.discourse"; 38 description = lib.mdDoc '' 39 The discourse package to use. 40 ''; 41 }; 42 43 hostname = lib.mkOption { 44 type = lib.types.str; 45 default = config.networking.fqdnOrHostName; 46 defaultText = lib.literalExpression "config.networking.fqdnOrHostName"; 47 example = "discourse.example.com"; 48 description = lib.mdDoc '' 49 The hostname to serve Discourse on. 50 ''; 51 }; 52 53 secretKeyBaseFile = lib.mkOption { 54 type = with lib.types; nullOr path; 55 default = null; 56 example = "/run/keys/secret_key_base"; 57 description = lib.mdDoc '' 58 The path to a file containing the 59 `secret_key_base` secret. 60 61 Discourse uses `secret_key_base` to encrypt 62 the cookie store, which contains session data, and to digest 63 user auth tokens. 64 65 Needs to be a 64 byte long string of hexadecimal 66 characters. You can generate one by running 67 68 ``` 69 openssl rand -hex 64 >/path/to/secret_key_base_file 70 ``` 71 72 This should be a string, not a nix path, since nix paths are 73 copied into the world-readable nix store. 74 ''; 75 }; 76 77 sslCertificate = lib.mkOption { 78 type = with lib.types; nullOr path; 79 default = null; 80 example = "/run/keys/ssl.cert"; 81 description = lib.mdDoc '' 82 The path to the server SSL certificate. Set this to enable 83 SSL. 84 ''; 85 }; 86 87 sslCertificateKey = lib.mkOption { 88 type = with lib.types; nullOr path; 89 default = null; 90 example = "/run/keys/ssl.key"; 91 description = lib.mdDoc '' 92 The path to the server SSL certificate key. Set this to 93 enable SSL. 94 ''; 95 }; 96 97 enableACME = lib.mkOption { 98 type = lib.types.bool; 99 default = cfg.sslCertificate == null && cfg.sslCertificateKey == null; 100 defaultText = lib.literalMD '' 101 `true`, unless {option}`services.discourse.sslCertificate` 102 and {option}`services.discourse.sslCertificateKey` are set. 103 ''; 104 description = lib.mdDoc '' 105 Whether an ACME certificate should be used to secure 106 connections to the server. 107 ''; 108 }; 109 110 backendSettings = lib.mkOption { 111 type = with lib.types; attrsOf (nullOr (oneOf [ str int bool float ])); 112 default = {}; 113 example = lib.literalExpression '' 114 { 115 max_reqs_per_ip_per_minute = 300; 116 max_reqs_per_ip_per_10_seconds = 60; 117 max_asset_reqs_per_ip_per_10_seconds = 250; 118 max_reqs_per_ip_mode = "warn+block"; 119 }; 120 ''; 121 description = lib.mdDoc '' 122 Additional settings to put in the 123 {file}`discourse.conf` file. 124 125 Look in the 126 [discourse_defaults.conf](https://github.com/discourse/discourse/blob/master/config/discourse_defaults.conf) 127 file in the upstream distribution to find available options. 128 129 Setting an option to `null` means 130 define variable, but leave right-hand side empty. 131 ''; 132 }; 133 134 siteSettings = lib.mkOption { 135 type = json.type; 136 default = {}; 137 example = lib.literalExpression '' 138 { 139 required = { 140 title = "My Cats"; 141 site_description = "Discuss My Cats (and be nice plz)"; 142 }; 143 login = { 144 enable_github_logins = true; 145 github_client_id = "a2f6dfe838cb3206ce20"; 146 github_client_secret._secret = /run/keys/discourse_github_client_secret; 147 }; 148 }; 149 ''; 150 description = lib.mdDoc '' 151 Discourse site settings. These are the settings that can be 152 changed from the UI. This only defines their default values: 153 they can still be overridden from the UI. 154 155 Available settings can be found by looking in the 156 [site_settings.yml](https://github.com/discourse/discourse/blob/master/config/site_settings.yml) 157 file of the upstream distribution. To find a setting's path, 158 you only need to care about the first two levels; i.e. its 159 category and name. See the example. 160 161 Settings containing secret data should be set to an 162 attribute set containing the attribute 163 `_secret` - a string pointing to a file 164 containing the value the option should be set to. See the 165 example to get a better picture of this: in the resulting 166 {file}`config/nixos_site_settings.json` file, 167 the `login.github_client_secret` key will 168 be set to the contents of the 169 {file}`/run/keys/discourse_github_client_secret` 170 file. 171 ''; 172 }; 173 174 admin = { 175 skipCreate = lib.mkOption { 176 type = lib.types.bool; 177 default = false; 178 description = lib.mdDoc '' 179 Do not create the admin account, instead rely on other 180 existing admin accounts. 181 ''; 182 }; 183 184 email = lib.mkOption { 185 type = lib.types.str; 186 example = "admin@example.com"; 187 description = lib.mdDoc '' 188 The admin user email address. 189 ''; 190 }; 191 192 username = lib.mkOption { 193 type = lib.types.str; 194 example = "admin"; 195 description = lib.mdDoc '' 196 The admin user username. 197 ''; 198 }; 199 200 fullName = lib.mkOption { 201 type = lib.types.str; 202 description = lib.mdDoc '' 203 The admin user's full name. 204 ''; 205 }; 206 207 passwordFile = lib.mkOption { 208 type = lib.types.path; 209 description = lib.mdDoc '' 210 A path to a file containing the admin user's password. 211 212 This should be a string, not a nix path, since nix paths are 213 copied into the world-readable nix store. 214 ''; 215 }; 216 }; 217 218 nginx.enable = lib.mkOption { 219 type = lib.types.bool; 220 default = true; 221 description = lib.mdDoc '' 222 Whether an `nginx` virtual host should be 223 set up to serve Discourse. Only disable if you're planning 224 to use a different web server, which is not recommended. 225 ''; 226 }; 227 228 database = { 229 pool = lib.mkOption { 230 type = lib.types.int; 231 default = 8; 232 description = lib.mdDoc '' 233 Database connection pool size. 234 ''; 235 }; 236 237 host = lib.mkOption { 238 type = with lib.types; nullOr str; 239 default = null; 240 description = lib.mdDoc '' 241 Discourse database hostname. `null` means 242 prefer local unix socket connection. 243 ''; 244 }; 245 246 passwordFile = lib.mkOption { 247 type = with lib.types; nullOr path; 248 default = null; 249 description = lib.mdDoc '' 250 File containing the Discourse database user password. 251 252 This should be a string, not a nix path, since nix paths are 253 copied into the world-readable nix store. 254 ''; 255 }; 256 257 createLocally = lib.mkOption { 258 type = lib.types.bool; 259 default = true; 260 description = lib.mdDoc '' 261 Whether a database should be automatically created on the 262 local host. Set this to `false` if you plan 263 on provisioning a local database yourself. This has no effect 264 if {option}`services.discourse.database.host` is customized. 265 ''; 266 }; 267 268 name = lib.mkOption { 269 type = lib.types.str; 270 default = "discourse"; 271 description = lib.mdDoc '' 272 Discourse database name. 273 ''; 274 }; 275 276 username = lib.mkOption { 277 type = lib.types.str; 278 default = "discourse"; 279 description = lib.mdDoc '' 280 Discourse database user. 281 ''; 282 }; 283 284 ignorePostgresqlVersion = lib.mkOption { 285 type = lib.types.bool; 286 default = false; 287 description = lib.mdDoc '' 288 Whether to allow other versions of PostgreSQL than the 289 recommended one. Only effective when 290 {option}`services.discourse.database.createLocally` 291 is enabled. 292 ''; 293 }; 294 }; 295 296 redis = { 297 host = lib.mkOption { 298 type = lib.types.str; 299 default = "localhost"; 300 description = lib.mdDoc '' 301 Redis server hostname. 302 ''; 303 }; 304 305 passwordFile = lib.mkOption { 306 type = with lib.types; nullOr path; 307 default = null; 308 description = lib.mdDoc '' 309 File containing the Redis password. 310 311 This should be a string, not a nix path, since nix paths are 312 copied into the world-readable nix store. 313 ''; 314 }; 315 316 dbNumber = lib.mkOption { 317 type = lib.types.int; 318 default = 0; 319 description = lib.mdDoc '' 320 Redis database number. 321 ''; 322 }; 323 324 useSSL = lib.mkOption { 325 type = lib.types.bool; 326 default = cfg.redis.host != "localhost"; 327 defaultText = lib.literalExpression ''config.${opt.redis.host} != "localhost"''; 328 description = lib.mdDoc '' 329 Connect to Redis with SSL. 330 ''; 331 }; 332 }; 333 334 mail = { 335 notificationEmailAddress = lib.mkOption { 336 type = lib.types.str; 337 default = "${if cfg.mail.incoming.enable then "notifications" else "noreply"}@${cfg.hostname}"; 338 defaultText = lib.literalExpression '' 339 "''${if config.services.discourse.mail.incoming.enable then "notifications" else "noreply"}@''${config.services.discourse.hostname}" 340 ''; 341 description = lib.mdDoc '' 342 The `from:` email address used when 343 sending all essential system emails. The domain specified 344 here must have SPF, DKIM and reverse PTR records set 345 correctly for email to arrive. 346 ''; 347 }; 348 349 contactEmailAddress = lib.mkOption { 350 type = lib.types.str; 351 default = ""; 352 description = lib.mdDoc '' 353 Email address of key contact responsible for this 354 site. Used for critical notifications, as well as on the 355 `/about` contact form for urgent matters. 356 ''; 357 }; 358 359 outgoing = { 360 serverAddress = lib.mkOption { 361 type = lib.types.str; 362 default = "localhost"; 363 description = lib.mdDoc '' 364 The address of the SMTP server Discourse should use to 365 send email. 366 ''; 367 }; 368 369 port = lib.mkOption { 370 type = lib.types.port; 371 default = 25; 372 description = lib.mdDoc '' 373 The port of the SMTP server Discourse should use to 374 send email. 375 ''; 376 }; 377 378 username = lib.mkOption { 379 type = with lib.types; nullOr str; 380 default = null; 381 description = lib.mdDoc '' 382 The username of the SMTP server. 383 ''; 384 }; 385 386 passwordFile = lib.mkOption { 387 type = lib.types.nullOr lib.types.path; 388 default = null; 389 description = lib.mdDoc '' 390 A file containing the password of the SMTP server account. 391 392 This should be a string, not a nix path, since nix paths 393 are copied into the world-readable nix store. 394 ''; 395 }; 396 397 domain = lib.mkOption { 398 type = lib.types.str; 399 default = cfg.hostname; 400 defaultText = lib.literalExpression "config.${opt.hostname}"; 401 description = lib.mdDoc '' 402 HELO domain to use for outgoing mail. 403 ''; 404 }; 405 406 authentication = lib.mkOption { 407 type = with lib.types; nullOr (enum ["plain" "login" "cram_md5"]); 408 default = null; 409 description = lib.mdDoc '' 410 Authentication type to use, see http://api.rubyonrails.org/classes/ActionMailer/Base.html 411 ''; 412 }; 413 414 enableStartTLSAuto = lib.mkOption { 415 type = lib.types.bool; 416 default = true; 417 description = lib.mdDoc '' 418 Whether to try to use StartTLS. 419 ''; 420 }; 421 422 opensslVerifyMode = lib.mkOption { 423 type = lib.types.str; 424 default = "peer"; 425 description = lib.mdDoc '' 426 How OpenSSL checks the certificate, see http://api.rubyonrails.org/classes/ActionMailer/Base.html 427 ''; 428 }; 429 430 forceTLS = lib.mkOption { 431 type = lib.types.bool; 432 default = false; 433 description = lib.mdDoc '' 434 Force implicit TLS as per RFC 8314 3.3. 435 ''; 436 }; 437 }; 438 439 incoming = { 440 enable = lib.mkOption { 441 type = lib.types.bool; 442 default = false; 443 description = lib.mdDoc '' 444 Whether to set up Postfix to receive incoming mail. 445 ''; 446 }; 447 448 replyEmailAddress = lib.mkOption { 449 type = lib.types.str; 450 default = "%{reply_key}@${cfg.hostname}"; 451 defaultText = lib.literalExpression ''"%{reply_key}@''${config.services.discourse.hostname}"''; 452 description = lib.mdDoc '' 453 Template for reply by email incoming email address, for 454 example: %{reply_key}@reply.example.com or 455 replies+%{reply_key}@example.com 456 ''; 457 }; 458 459 mailReceiverPackage = lib.mkOption { 460 type = lib.types.package; 461 default = pkgs.discourse-mail-receiver; 462 defaultText = lib.literalExpression "pkgs.discourse-mail-receiver"; 463 description = lib.mdDoc '' 464 The discourse-mail-receiver package to use. 465 ''; 466 }; 467 468 apiKeyFile = lib.mkOption { 469 type = lib.types.nullOr lib.types.path; 470 default = null; 471 description = lib.mdDoc '' 472 A file containing the Discourse API key used to add 473 posts and messages from mail. If left at its default 474 value `null`, one will be automatically 475 generated. 476 477 This should be a string, not a nix path, since nix paths 478 are copied into the world-readable nix store. 479 ''; 480 }; 481 }; 482 }; 483 484 plugins = lib.mkOption { 485 type = lib.types.listOf lib.types.package; 486 default = []; 487 example = lib.literalExpression '' 488 with config.services.discourse.package.plugins; [ 489 discourse-canned-replies 490 discourse-github 491 ]; 492 ''; 493 description = lib.mdDoc '' 494 Plugins to install as part of Discourse, expressed as a list of derivations. 495 ''; 496 }; 497 498 sidekiqProcesses = lib.mkOption { 499 type = lib.types.int; 500 default = 1; 501 description = lib.mdDoc '' 502 How many Sidekiq processes should be spawned. 503 ''; 504 }; 505 506 unicornTimeout = lib.mkOption { 507 type = lib.types.int; 508 default = 30; 509 description = lib.mdDoc '' 510 Time in seconds before a request to Unicorn times out. 511 512 This can be raised if the system Discourse is running on is 513 too slow to handle many requests within 30 seconds. 514 ''; 515 }; 516 }; 517 }; 518 519 config = lib.mkIf cfg.enable { 520 assertions = [ 521 { 522 assertion = (cfg.database.host != null) -> (cfg.database.passwordFile != null); 523 message = "When services.gitlab.database.host is customized, services.discourse.database.passwordFile must be set!"; 524 } 525 { 526 assertion = cfg.hostname != ""; 527 message = "Could not automatically determine hostname, set service.discourse.hostname manually."; 528 } 529 { 530 assertion = cfg.database.ignorePostgresqlVersion || (databaseActuallyCreateLocally -> upstreamPostgresqlVersion == postgresqlVersion); 531 message = "The PostgreSQL version recommended for use with Discourse is ${upstreamPostgresqlVersion}, you're using ${postgresqlVersion}. " 532 + "Either update your PostgreSQL package to the correct version or set services.discourse.database.ignorePostgresqlVersion. " 533 + "See https://nixos.org/manual/nixos/stable/index.html#module-postgresql for details on how to upgrade PostgreSQL."; 534 } 535 ]; 536 537 538 # Default config values are from `config/discourse_defaults.conf` 539 # upstream. 540 services.discourse.backendSettings = lib.mapAttrs (_: lib.mkDefault) { 541 db_pool = cfg.database.pool; 542 db_timeout = 5000; 543 db_connect_timeout = 5; 544 db_socket = null; 545 db_host = cfg.database.host; 546 db_backup_host = null; 547 db_port = null; 548 db_backup_port = 5432; 549 db_name = cfg.database.name; 550 db_username = if databaseActuallyCreateLocally then "discourse" else cfg.database.username; 551 db_password = cfg.database.passwordFile; 552 db_prepared_statements = false; 553 db_replica_host = null; 554 db_replica_port = null; 555 db_advisory_locks = true; 556 557 inherit (cfg) hostname; 558 backup_hostname = null; 559 560 smtp_address = cfg.mail.outgoing.serverAddress; 561 smtp_port = cfg.mail.outgoing.port; 562 smtp_domain = cfg.mail.outgoing.domain; 563 smtp_user_name = cfg.mail.outgoing.username; 564 smtp_password = cfg.mail.outgoing.passwordFile; 565 smtp_authentication = cfg.mail.outgoing.authentication; 566 smtp_enable_start_tls = cfg.mail.outgoing.enableStartTLSAuto; 567 smtp_openssl_verify_mode = cfg.mail.outgoing.opensslVerifyMode; 568 smtp_force_tls = cfg.mail.outgoing.forceTLS; 569 570 load_mini_profiler = true; 571 mini_profiler_snapshots_period = 0; 572 mini_profiler_snapshots_transport_url = null; 573 mini_profiler_snapshots_transport_auth_key = null; 574 575 cdn_url = null; 576 cdn_origin_hostname = null; 577 developer_emails = null; 578 579 redis_host = cfg.redis.host; 580 redis_port = 6379; 581 redis_replica_host = null; 582 redis_replica_port = 6379; 583 redis_db = cfg.redis.dbNumber; 584 redis_password = cfg.redis.passwordFile; 585 redis_skip_client_commands = false; 586 redis_use_ssl = cfg.redis.useSSL; 587 588 message_bus_redis_enabled = false; 589 message_bus_redis_host = "localhost"; 590 message_bus_redis_port = 6379; 591 message_bus_redis_replica_host = null; 592 message_bus_redis_replica_port = 6379; 593 message_bus_redis_db = 0; 594 message_bus_redis_password = null; 595 message_bus_redis_skip_client_commands = false; 596 597 enable_cors = false; 598 cors_origin = ""; 599 serve_static_assets = false; 600 sidekiq_workers = 5; 601 connection_reaper_age = 30; 602 connection_reaper_interval = 30; 603 relative_url_root = null; 604 message_bus_max_backlog_size = 100; 605 message_bus_clear_every = 50; 606 secret_key_base = cfg.secretKeyBaseFile; 607 fallback_assets_path = null; 608 609 s3_bucket = null; 610 s3_region = null; 611 s3_access_key_id = null; 612 s3_secret_access_key = null; 613 s3_use_iam_profile = null; 614 s3_cdn_url = null; 615 s3_endpoint = null; 616 s3_http_continue_timeout = null; 617 s3_install_cors_rule = null; 618 619 max_user_api_reqs_per_minute = 20; 620 max_user_api_reqs_per_day = 2880; 621 max_admin_api_reqs_per_minute = 60; 622 max_reqs_per_ip_per_minute = 200; 623 max_reqs_per_ip_per_10_seconds = 50; 624 max_asset_reqs_per_ip_per_10_seconds = 200; 625 max_reqs_per_ip_mode = "block"; 626 max_reqs_rate_limit_on_private = false; 627 skip_per_ip_rate_limit_trust_level = 1; 628 force_anonymous_min_queue_seconds = 1; 629 force_anonymous_min_per_10_seconds = 3; 630 background_requests_max_queue_length = 0.5; 631 reject_message_bus_queue_seconds = 0.1; 632 disable_search_queue_threshold = 1; 633 max_old_rebakes_per_15_minutes = 300; 634 max_logster_logs = 1000; 635 refresh_maxmind_db_during_precompile_days = 2; 636 maxmind_backup_path = null; 637 maxmind_license_key = null; 638 enable_performance_http_headers = false; 639 enable_js_error_reporting = true; 640 mini_scheduler_workers = 5; 641 compress_anon_cache = false; 642 anon_cache_store_threshold = 2; 643 allowed_theme_repos = null; 644 enable_email_sync_demon = false; 645 max_digests_enqueued_per_30_mins_per_site = 10000; 646 cluster_name = null; 647 multisite_config_path = "config/multisite.yml"; 648 enable_long_polling = null; 649 long_polling_interval = null; 650 }; 651 652 services.redis.servers.discourse = 653 lib.mkIf (lib.elem cfg.redis.host [ "localhost" "127.0.0.1" ]) { 654 enable = true; 655 bind = cfg.redis.host; 656 port = cfg.backendSettings.redis_port; 657 }; 658 659 services.postgresql = lib.mkIf databaseActuallyCreateLocally { 660 enable = true; 661 ensureUsers = [{ name = "discourse"; }]; 662 }; 663 664 # The postgresql module doesn't currently support concepts like 665 # objects owners and extensions; for now we tack on what's needed 666 # here. 667 systemd.services.discourse-postgresql = 668 let 669 pgsql = config.services.postgresql; 670 in 671 lib.mkIf databaseActuallyCreateLocally { 672 after = [ "postgresql.service" ]; 673 bindsTo = [ "postgresql.service" ]; 674 wantedBy = [ "discourse.service" ]; 675 partOf = [ "discourse.service" ]; 676 path = [ 677 pgsql.package 678 ]; 679 script = '' 680 set -o errexit -o pipefail -o nounset -o errtrace 681 shopt -s inherit_errexit 682 683 psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'discourse'" | grep -q 1 || psql -tAc 'CREATE DATABASE "discourse" OWNER "discourse"' 684 psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS pg_trgm" 685 psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS hstore" 686 ''; 687 688 serviceConfig = { 689 User = pgsql.superUser; 690 Type = "oneshot"; 691 RemainAfterExit = true; 692 }; 693 }; 694 695 systemd.services.discourse = { 696 wantedBy = [ "multi-user.target" ]; 697 after = [ 698 "redis-discourse.service" 699 "postgresql.service" 700 "discourse-postgresql.service" 701 ]; 702 bindsTo = [ 703 "redis-discourse.service" 704 ] ++ lib.optionals (cfg.database.host == null) [ 705 "postgresql.service" 706 "discourse-postgresql.service" 707 ]; 708 path = cfg.package.runtimeDeps ++ [ 709 postgresqlPackage 710 pkgs.replace-secret 711 cfg.package.rake 712 ]; 713 environment = cfg.package.runtimeEnv // { 714 UNICORN_TIMEOUT = builtins.toString cfg.unicornTimeout; 715 UNICORN_SIDEKIQS = builtins.toString cfg.sidekiqProcesses; 716 MALLOC_ARENA_MAX = "2"; 717 }; 718 719 preStart = 720 let 721 discourseKeyValue = lib.generators.toKeyValue { 722 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault " = " { 723 mkValueString = v: with builtins; 724 if isInt v then toString v 725 else if isString v then ''"${v}"'' 726 else if true == v then "true" 727 else if false == v then "false" 728 else if null == v then "" 729 else if isFloat v then lib.strings.floatToString v 730 else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}"; 731 }; 732 }; 733 734 discourseConf = pkgs.writeText "discourse.conf" (discourseKeyValue cfg.backendSettings); 735 736 mkSecretReplacement = file: 737 lib.optionalString (file != null) '' 738 replace-secret '${file}' '${file}' /run/discourse/config/discourse.conf 739 ''; 740 741 mkAdmin = '' 742 export ADMIN_EMAIL="${cfg.admin.email}" 743 export ADMIN_NAME="${cfg.admin.fullName}" 744 export ADMIN_USERNAME="${cfg.admin.username}" 745 ADMIN_PASSWORD="$(<${cfg.admin.passwordFile})" 746 export ADMIN_PASSWORD 747 discourse-rake admin:create_noninteractively 748 ''; 749 750 in '' 751 set -o errexit -o pipefail -o nounset -o errtrace 752 shopt -s inherit_errexit 753 754 umask u=rwx,g=rx,o= 755 756 rm -rf /var/lib/discourse/tmp/* 757 758 cp -r ${cfg.package}/share/discourse/config.dist/* /run/discourse/config/ 759 cp -r ${cfg.package}/share/discourse/public.dist/* /run/discourse/public/ 760 ln -sf /var/lib/discourse/uploads /run/discourse/public/uploads 761 ln -sf /var/lib/discourse/backups /run/discourse/public/backups 762 763 ( 764 umask u=rwx,g=,o= 765 766 ${utils.genJqSecretsReplacementSnippet 767 cfg.siteSettings 768 "/run/discourse/config/nixos_site_settings.json" 769 } 770 install -T -m 0600 -o discourse ${discourseConf} /run/discourse/config/discourse.conf 771 ${mkSecretReplacement cfg.database.passwordFile} 772 ${mkSecretReplacement cfg.mail.outgoing.passwordFile} 773 ${mkSecretReplacement cfg.redis.passwordFile} 774 ${mkSecretReplacement cfg.secretKeyBaseFile} 775 chmod 0400 /run/discourse/config/discourse.conf 776 ) 777 778 discourse-rake db:migrate >>/var/log/discourse/db_migration.log 779 chmod -R u+w /var/lib/discourse/tmp/ 780 781 ${lib.optionalString (!cfg.admin.skipCreate) mkAdmin} 782 783 discourse-rake themes:update 784 discourse-rake uploads:regenerate_missing_optimized 785 ''; 786 787 serviceConfig = { 788 Type = "simple"; 789 User = "discourse"; 790 Group = "discourse"; 791 RuntimeDirectory = map (p: "discourse/" + p) [ 792 "config" 793 "home" 794 "assets/javascripts/plugins" 795 "public" 796 "sockets" 797 ]; 798 RuntimeDirectoryMode = "0750"; 799 StateDirectory = map (p: "discourse/" + p) [ 800 "uploads" 801 "backups" 802 "tmp" 803 ]; 804 StateDirectoryMode = "0750"; 805 LogsDirectory = "discourse"; 806 TimeoutSec = "infinity"; 807 Restart = "on-failure"; 808 WorkingDirectory = "${cfg.package}/share/discourse"; 809 810 RemoveIPC = true; 811 PrivateTmp = true; 812 NoNewPrivileges = true; 813 RestrictSUIDSGID = true; 814 ProtectSystem = "strict"; 815 ProtectHome = "read-only"; 816 817 ExecStart = "${cfg.package.rubyEnv}/bin/bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb"; 818 }; 819 }; 820 821 services.nginx = lib.mkIf cfg.nginx.enable { 822 enable = true; 823 additionalModules = [ pkgs.nginxModules.brotli ]; 824 825 recommendedTlsSettings = true; 826 recommendedOptimisation = true; 827 recommendedGzipSettings = true; 828 recommendedProxySettings = true; 829 830 upstreams.discourse.servers."unix:/run/discourse/sockets/unicorn.sock" = {}; 831 832 appendHttpConfig = '' 833 # inactive means we keep stuff around for 1440m minutes regardless of last access (1 week) 834 # levels means it is a 2 deep hierarchy cause we can have lots of files 835 # max_size limits the size of the cache 836 proxy_cache_path /var/cache/nginx inactive=1440m levels=1:2 keys_zone=discourse:10m max_size=600m; 837 838 # see: https://meta.discourse.org/t/x/74060 839 proxy_buffer_size 8k; 840 ''; 841 842 virtualHosts.${cfg.hostname} = { 843 inherit (cfg) sslCertificate sslCertificateKey enableACME; 844 forceSSL = lib.mkDefault tlsEnabled; 845 846 root = "${cfg.package}/share/discourse/public"; 847 848 locations = 849 let 850 proxy = { extraConfig ? "" }: { 851 proxyPass = "http://discourse"; 852 extraConfig = extraConfig + '' 853 proxy_set_header X-Request-Start "t=''${msec}"; 854 ''; 855 }; 856 cache = time: '' 857 expires ${time}; 858 add_header Cache-Control public,immutable; 859 ''; 860 cache_1y = cache "1y"; 861 cache_1d = cache "1d"; 862 in 863 { 864 "/".tryFiles = "$uri @discourse"; 865 "@discourse" = proxy {}; 866 "^~ /backups/".extraConfig = '' 867 internal; 868 ''; 869 "/favicon.ico" = { 870 return = "204"; 871 extraConfig = '' 872 access_log off; 873 log_not_found off; 874 ''; 875 }; 876 "~ ^/uploads/short-url/" = proxy {}; 877 "~ ^/secure-media-uploads/" = proxy {}; 878 "~* (fonts|assets|plugins|uploads)/.*\.(eot|ttf|woff|woff2|ico|otf)$".extraConfig = cache_1y + '' 879 add_header Access-Control-Allow-Origin *; 880 ''; 881 "/srv/status" = proxy { 882 extraConfig = '' 883 access_log off; 884 log_not_found off; 885 ''; 886 }; 887 "~ ^/javascripts/".extraConfig = cache_1d; 888 "~ ^/assets/(?<asset_path>.+)$".extraConfig = cache_1y + '' 889 # asset pipeline enables this 890 brotli_static on; 891 gzip_static on; 892 ''; 893 "~ ^/plugins/".extraConfig = cache_1y; 894 "~ /images/emoji/".extraConfig = cache_1y; 895 "~ ^/uploads/" = proxy { 896 extraConfig = cache_1y + '' 897 proxy_set_header X-Sendfile-Type X-Accel-Redirect; 898 proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/; 899 900 # custom CSS 901 location ~ /stylesheet-cache/ { 902 try_files $uri =404; 903 } 904 # this allows us to bypass rails 905 location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp)$ { 906 try_files $uri =404; 907 } 908 # SVG needs an extra header attached 909 location ~* \.(svg)$ { 910 } 911 # thumbnails & optimized images 912 location ~ /_?optimized/ { 913 try_files $uri =404; 914 } 915 ''; 916 }; 917 "~ ^/admin/backups/" = proxy { 918 extraConfig = '' 919 proxy_set_header X-Sendfile-Type X-Accel-Redirect; 920 proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/; 921 ''; 922 }; 923 "~ ^/(svg-sprite/|letter_avatar/|letter_avatar_proxy/|user_avatar|highlight-js|stylesheets|theme-javascripts|favicon/proxied|service-worker)" = proxy { 924 extraConfig = '' 925 # if Set-Cookie is in the response nothing gets cached 926 # this is double bad cause we are not passing last modified in 927 proxy_ignore_headers "Set-Cookie"; 928 proxy_hide_header "Set-Cookie"; 929 proxy_hide_header "X-Discourse-Username"; 930 proxy_hide_header "X-Runtime"; 931 932 # note x-accel-redirect can not be used with proxy_cache 933 proxy_cache discourse; 934 proxy_cache_key "$scheme,$host,$request_uri"; 935 proxy_cache_valid 200 301 302 7d; 936 ''; 937 }; 938 "/message-bus/" = proxy { 939 extraConfig = '' 940 proxy_http_version 1.1; 941 proxy_buffering off; 942 ''; 943 }; 944 "/downloads/".extraConfig = '' 945 internal; 946 alias ${cfg.package}/share/discourse/public/; 947 ''; 948 }; 949 }; 950 }; 951 952 systemd.services.discourse-mail-receiver-setup = lib.mkIf cfg.mail.incoming.enable ( 953 let 954 mail-receiver-environment = { 955 MAIL_DOMAIN = cfg.hostname; 956 DISCOURSE_BASE_URL = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}"; 957 DISCOURSE_API_KEY = "@api-key@"; 958 DISCOURSE_API_USERNAME = "system"; 959 }; 960 mail-receiver-json = json.generate "mail-receiver.json" mail-receiver-environment; 961 in 962 { 963 before = [ "postfix.service" ]; 964 after = [ "discourse.service" ]; 965 wantedBy = [ "discourse.service" ]; 966 partOf = [ "discourse.service" ]; 967 path = [ 968 cfg.package.rake 969 pkgs.jq 970 ]; 971 preStart = lib.optionalString (cfg.mail.incoming.apiKeyFile == null) '' 972 set -o errexit -o pipefail -o nounset -o errtrace 973 shopt -s inherit_errexit 974 975 if [[ ! -e /var/lib/discourse-mail-receiver/api_key ]]; then 976 discourse-rake api_key:create_master[email-receiver] >/var/lib/discourse-mail-receiver/api_key 977 fi 978 ''; 979 script = 980 let 981 apiKeyPath = 982 if cfg.mail.incoming.apiKeyFile == null then 983 "/var/lib/discourse-mail-receiver/api_key" 984 else 985 cfg.mail.incoming.apiKeyFile; 986 in '' 987 set -o errexit -o pipefail -o nounset -o errtrace 988 shopt -s inherit_errexit 989 990 api_key=$(<'${apiKeyPath}') 991 export api_key 992 993 jq <${mail-receiver-json} \ 994 '.DISCOURSE_API_KEY = $ENV.api_key' \ 995 >'/run/discourse-mail-receiver/mail-receiver-environment.json' 996 ''; 997 998 serviceConfig = { 999 Type = "oneshot"; 1000 RemainAfterExit = true; 1001 RuntimeDirectory = "discourse-mail-receiver"; 1002 RuntimeDirectoryMode = "0700"; 1003 StateDirectory = "discourse-mail-receiver"; 1004 User = "discourse"; 1005 Group = "discourse"; 1006 }; 1007 }); 1008 1009 services.discourse.siteSettings = { 1010 required = { 1011 notification_email = cfg.mail.notificationEmailAddress; 1012 contact_email = cfg.mail.contactEmailAddress; 1013 }; 1014 email = { 1015 manual_polling_enabled = cfg.mail.incoming.enable; 1016 reply_by_email_enabled = cfg.mail.incoming.enable; 1017 reply_by_email_address = cfg.mail.incoming.replyEmailAddress; 1018 }; 1019 }; 1020 1021 services.postfix = lib.mkIf cfg.mail.incoming.enable { 1022 enable = true; 1023 sslCert = if cfg.sslCertificate != null then cfg.sslCertificate else ""; 1024 sslKey = if cfg.sslCertificateKey != null then cfg.sslCertificateKey else ""; 1025 1026 origin = cfg.hostname; 1027 relayDomains = [ cfg.hostname ]; 1028 config = { 1029 smtpd_recipient_restrictions = "check_policy_service unix:private/discourse-policy"; 1030 append_dot_mydomain = lib.mkDefault false; 1031 compatibility_level = "2"; 1032 smtputf8_enable = false; 1033 smtpd_banner = lib.mkDefault "ESMTP server"; 1034 myhostname = lib.mkDefault cfg.hostname; 1035 mydestination = lib.mkDefault "localhost"; 1036 }; 1037 transport = '' 1038 ${cfg.hostname} discourse-mail-receiver: 1039 ''; 1040 masterConfig = { 1041 "discourse-mail-receiver" = { 1042 type = "unix"; 1043 privileged = true; 1044 chroot = false; 1045 command = "pipe"; 1046 args = [ 1047 "user=discourse" 1048 "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/receive-mail" 1049 "\${recipient}" 1050 ]; 1051 }; 1052 "discourse-policy" = { 1053 type = "unix"; 1054 privileged = true; 1055 chroot = false; 1056 command = "spawn"; 1057 args = [ 1058 "user=discourse" 1059 "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/discourse-smtp-fast-rejection" 1060 ]; 1061 }; 1062 }; 1063 }; 1064 1065 users.users = { 1066 discourse = { 1067 group = "discourse"; 1068 isSystemUser = true; 1069 }; 1070 } // (lib.optionalAttrs cfg.nginx.enable { 1071 ${config.services.nginx.user}.extraGroups = [ "discourse" ]; 1072 }); 1073 1074 users.groups = { 1075 discourse = {}; 1076 }; 1077 1078 environment.systemPackages = [ 1079 cfg.package.rake 1080 ]; 1081 }; 1082 1083 meta.doc = ./discourse.xml; 1084 meta.maintainers = [ lib.maintainers.talyz ]; 1085}