at 23.11-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 s3_asset_cdn_url = null; 619 620 max_user_api_reqs_per_minute = 20; 621 max_user_api_reqs_per_day = 2880; 622 max_admin_api_reqs_per_minute = 60; 623 max_reqs_per_ip_per_minute = 200; 624 max_reqs_per_ip_per_10_seconds = 50; 625 max_asset_reqs_per_ip_per_10_seconds = 200; 626 max_reqs_per_ip_mode = "block"; 627 max_reqs_rate_limit_on_private = false; 628 skip_per_ip_rate_limit_trust_level = 1; 629 force_anonymous_min_queue_seconds = 1; 630 force_anonymous_min_per_10_seconds = 3; 631 background_requests_max_queue_length = 0.5; 632 reject_message_bus_queue_seconds = 0.1; 633 disable_search_queue_threshold = 1; 634 max_old_rebakes_per_15_minutes = 300; 635 max_logster_logs = 1000; 636 refresh_maxmind_db_during_precompile_days = 2; 637 maxmind_backup_path = null; 638 maxmind_license_key = null; 639 enable_performance_http_headers = false; 640 enable_js_error_reporting = true; 641 mini_scheduler_workers = 5; 642 compress_anon_cache = false; 643 anon_cache_store_threshold = 2; 644 allowed_theme_repos = null; 645 enable_email_sync_demon = false; 646 max_digests_enqueued_per_30_mins_per_site = 10000; 647 cluster_name = null; 648 multisite_config_path = "config/multisite.yml"; 649 enable_long_polling = null; 650 long_polling_interval = null; 651 preload_link_header = false; 652 redirect_avatar_requests = false; 653 pg_force_readonly_mode = false; 654 dns_query_timeout_secs = null; 655 regex_timeout_seconds = 2; 656 allow_impersonation = true; 657 }; 658 659 services.redis.servers.discourse = 660 lib.mkIf (lib.elem cfg.redis.host [ "localhost" "127.0.0.1" ]) { 661 enable = true; 662 bind = cfg.redis.host; 663 port = cfg.backendSettings.redis_port; 664 }; 665 666 services.postgresql = lib.mkIf databaseActuallyCreateLocally { 667 enable = true; 668 ensureUsers = [{ name = "discourse"; }]; 669 }; 670 671 # The postgresql module doesn't currently support concepts like 672 # objects owners and extensions; for now we tack on what's needed 673 # here. 674 systemd.services.discourse-postgresql = 675 let 676 pgsql = config.services.postgresql; 677 in 678 lib.mkIf databaseActuallyCreateLocally { 679 after = [ "postgresql.service" ]; 680 bindsTo = [ "postgresql.service" ]; 681 wantedBy = [ "discourse.service" ]; 682 partOf = [ "discourse.service" ]; 683 path = [ 684 pgsql.package 685 ]; 686 script = '' 687 set -o errexit -o pipefail -o nounset -o errtrace 688 shopt -s inherit_errexit 689 690 psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'discourse'" | grep -q 1 || psql -tAc 'CREATE DATABASE "discourse" OWNER "discourse"' 691 psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS pg_trgm" 692 psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS hstore" 693 ''; 694 695 serviceConfig = { 696 User = pgsql.superUser; 697 Type = "oneshot"; 698 RemainAfterExit = true; 699 }; 700 }; 701 702 systemd.services.discourse = { 703 wantedBy = [ "multi-user.target" ]; 704 after = [ 705 "redis-discourse.service" 706 "postgresql.service" 707 "discourse-postgresql.service" 708 ]; 709 bindsTo = [ 710 "redis-discourse.service" 711 ] ++ lib.optionals (cfg.database.host == null) [ 712 "postgresql.service" 713 "discourse-postgresql.service" 714 ]; 715 path = cfg.package.runtimeDeps ++ [ 716 postgresqlPackage 717 pkgs.replace-secret 718 cfg.package.rake 719 ]; 720 environment = cfg.package.runtimeEnv // { 721 UNICORN_TIMEOUT = builtins.toString cfg.unicornTimeout; 722 UNICORN_SIDEKIQS = builtins.toString cfg.sidekiqProcesses; 723 MALLOC_ARENA_MAX = "2"; 724 }; 725 726 preStart = 727 let 728 discourseKeyValue = lib.generators.toKeyValue { 729 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault " = " { 730 mkValueString = v: with builtins; 731 if isInt v then toString v 732 else if isString v then ''"${v}"'' 733 else if true == v then "true" 734 else if false == v then "false" 735 else if null == v then "" 736 else if isFloat v then lib.strings.floatToString v 737 else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}"; 738 }; 739 }; 740 741 discourseConf = pkgs.writeText "discourse.conf" (discourseKeyValue cfg.backendSettings); 742 743 mkSecretReplacement = file: 744 lib.optionalString (file != null) '' 745 replace-secret '${file}' '${file}' /run/discourse/config/discourse.conf 746 ''; 747 748 mkAdmin = '' 749 export ADMIN_EMAIL="${cfg.admin.email}" 750 export ADMIN_NAME="${cfg.admin.fullName}" 751 export ADMIN_USERNAME="${cfg.admin.username}" 752 ADMIN_PASSWORD="$(<${cfg.admin.passwordFile})" 753 export ADMIN_PASSWORD 754 discourse-rake admin:create_noninteractively 755 ''; 756 757 in '' 758 set -o errexit -o pipefail -o nounset -o errtrace 759 shopt -s inherit_errexit 760 761 umask u=rwx,g=rx,o= 762 763 rm -rf /var/lib/discourse/tmp/* 764 765 cp -r ${cfg.package}/share/discourse/config.dist/* /run/discourse/config/ 766 cp -r ${cfg.package}/share/discourse/public.dist/* /run/discourse/public/ 767 ln -sf /var/lib/discourse/uploads /run/discourse/public/uploads 768 ln -sf /var/lib/discourse/backups /run/discourse/public/backups 769 770 ( 771 umask u=rwx,g=,o= 772 773 ${utils.genJqSecretsReplacementSnippet 774 cfg.siteSettings 775 "/run/discourse/config/nixos_site_settings.json" 776 } 777 install -T -m 0600 -o discourse ${discourseConf} /run/discourse/config/discourse.conf 778 ${mkSecretReplacement cfg.database.passwordFile} 779 ${mkSecretReplacement cfg.mail.outgoing.passwordFile} 780 ${mkSecretReplacement cfg.redis.passwordFile} 781 ${mkSecretReplacement cfg.secretKeyBaseFile} 782 chmod 0400 /run/discourse/config/discourse.conf 783 ) 784 785 discourse-rake db:migrate >>/var/log/discourse/db_migration.log 786 chmod -R u+w /var/lib/discourse/tmp/ 787 788 ${lib.optionalString (!cfg.admin.skipCreate) mkAdmin} 789 790 discourse-rake themes:update 791 discourse-rake uploads:regenerate_missing_optimized 792 ''; 793 794 serviceConfig = { 795 Type = "simple"; 796 User = "discourse"; 797 Group = "discourse"; 798 RuntimeDirectory = map (p: "discourse/" + p) [ 799 "config" 800 "home" 801 "assets/javascripts/plugins" 802 "public" 803 "sockets" 804 ]; 805 RuntimeDirectoryMode = "0750"; 806 StateDirectory = map (p: "discourse/" + p) [ 807 "uploads" 808 "backups" 809 "tmp" 810 ]; 811 StateDirectoryMode = "0750"; 812 LogsDirectory = "discourse"; 813 TimeoutSec = "infinity"; 814 Restart = "on-failure"; 815 WorkingDirectory = "${cfg.package}/share/discourse"; 816 817 RemoveIPC = true; 818 PrivateTmp = true; 819 NoNewPrivileges = true; 820 RestrictSUIDSGID = true; 821 ProtectSystem = "strict"; 822 ProtectHome = "read-only"; 823 824 ExecStart = "${cfg.package.rubyEnv}/bin/bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb"; 825 }; 826 }; 827 828 services.nginx = lib.mkIf cfg.nginx.enable { 829 enable = true; 830 831 recommendedTlsSettings = true; 832 recommendedOptimisation = true; 833 recommendedBrotliSettings = true; 834 recommendedGzipSettings = true; 835 recommendedProxySettings = true; 836 837 upstreams.discourse.servers."unix:/run/discourse/sockets/unicorn.sock" = {}; 838 839 appendHttpConfig = '' 840 # inactive means we keep stuff around for 1440m minutes regardless of last access (1 week) 841 # levels means it is a 2 deep hierarchy cause we can have lots of files 842 # max_size limits the size of the cache 843 proxy_cache_path /var/cache/nginx inactive=1440m levels=1:2 keys_zone=discourse:10m max_size=600m; 844 845 # see: https://meta.discourse.org/t/x/74060 846 proxy_buffer_size 8k; 847 ''; 848 849 virtualHosts.${cfg.hostname} = { 850 inherit (cfg) sslCertificate sslCertificateKey enableACME; 851 forceSSL = lib.mkDefault tlsEnabled; 852 853 root = "${cfg.package}/share/discourse/public"; 854 855 locations = 856 let 857 proxy = { extraConfig ? "" }: { 858 proxyPass = "http://discourse"; 859 extraConfig = extraConfig + '' 860 proxy_set_header X-Request-Start "t=''${msec}"; 861 ''; 862 }; 863 cache = time: '' 864 expires ${time}; 865 add_header Cache-Control public,immutable; 866 ''; 867 cache_1y = cache "1y"; 868 cache_1d = cache "1d"; 869 in 870 { 871 "/".tryFiles = "$uri @discourse"; 872 "@discourse" = proxy {}; 873 "^~ /backups/".extraConfig = '' 874 internal; 875 ''; 876 "/favicon.ico" = { 877 return = "204"; 878 extraConfig = '' 879 access_log off; 880 log_not_found off; 881 ''; 882 }; 883 "~ ^/uploads/short-url/" = proxy {}; 884 "~ ^/secure-media-uploads/" = proxy {}; 885 "~* (fonts|assets|plugins|uploads)/.*\.(eot|ttf|woff|woff2|ico|otf)$".extraConfig = cache_1y + '' 886 add_header Access-Control-Allow-Origin *; 887 ''; 888 "/srv/status" = proxy { 889 extraConfig = '' 890 access_log off; 891 log_not_found off; 892 ''; 893 }; 894 "~ ^/javascripts/".extraConfig = cache_1d; 895 "~ ^/assets/(?<asset_path>.+)$".extraConfig = cache_1y + '' 896 # asset pipeline enables this 897 brotli_static on; 898 gzip_static on; 899 ''; 900 "~ ^/plugins/".extraConfig = cache_1y; 901 "~ /images/emoji/".extraConfig = cache_1y; 902 "~ ^/uploads/" = proxy { 903 extraConfig = cache_1y + '' 904 proxy_set_header X-Sendfile-Type X-Accel-Redirect; 905 proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/; 906 907 # custom CSS 908 location ~ /stylesheet-cache/ { 909 try_files $uri =404; 910 } 911 # this allows us to bypass rails 912 location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp)$ { 913 try_files $uri =404; 914 } 915 # SVG needs an extra header attached 916 location ~* \.(svg)$ { 917 } 918 # thumbnails & optimized images 919 location ~ /_?optimized/ { 920 try_files $uri =404; 921 } 922 ''; 923 }; 924 "~ ^/admin/backups/" = proxy { 925 extraConfig = '' 926 proxy_set_header X-Sendfile-Type X-Accel-Redirect; 927 proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/; 928 ''; 929 }; 930 "~ ^/(svg-sprite/|letter_avatar/|letter_avatar_proxy/|user_avatar|highlight-js|stylesheets|theme-javascripts|favicon/proxied|service-worker)" = proxy { 931 extraConfig = '' 932 # if Set-Cookie is in the response nothing gets cached 933 # this is double bad cause we are not passing last modified in 934 proxy_ignore_headers "Set-Cookie"; 935 proxy_hide_header "Set-Cookie"; 936 proxy_hide_header "X-Discourse-Username"; 937 proxy_hide_header "X-Runtime"; 938 939 # note x-accel-redirect can not be used with proxy_cache 940 proxy_cache discourse; 941 proxy_cache_key "$scheme,$host,$request_uri"; 942 proxy_cache_valid 200 301 302 7d; 943 ''; 944 }; 945 "/message-bus/" = proxy { 946 extraConfig = '' 947 proxy_http_version 1.1; 948 proxy_buffering off; 949 ''; 950 }; 951 "/downloads/".extraConfig = '' 952 internal; 953 alias ${cfg.package}/share/discourse/public/; 954 ''; 955 }; 956 }; 957 }; 958 959 systemd.services.discourse-mail-receiver-setup = lib.mkIf cfg.mail.incoming.enable ( 960 let 961 mail-receiver-environment = { 962 MAIL_DOMAIN = cfg.hostname; 963 DISCOURSE_BASE_URL = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}"; 964 DISCOURSE_API_KEY = "@api-key@"; 965 DISCOURSE_API_USERNAME = "system"; 966 }; 967 mail-receiver-json = json.generate "mail-receiver.json" mail-receiver-environment; 968 in 969 { 970 before = [ "postfix.service" ]; 971 after = [ "discourse.service" ]; 972 wantedBy = [ "discourse.service" ]; 973 partOf = [ "discourse.service" ]; 974 path = [ 975 cfg.package.rake 976 pkgs.jq 977 ]; 978 preStart = lib.optionalString (cfg.mail.incoming.apiKeyFile == null) '' 979 set -o errexit -o pipefail -o nounset -o errtrace 980 shopt -s inherit_errexit 981 982 if [[ ! -e /var/lib/discourse-mail-receiver/api_key ]]; then 983 discourse-rake api_key:create_master[email-receiver] >/var/lib/discourse-mail-receiver/api_key 984 fi 985 ''; 986 script = 987 let 988 apiKeyPath = 989 if cfg.mail.incoming.apiKeyFile == null then 990 "/var/lib/discourse-mail-receiver/api_key" 991 else 992 cfg.mail.incoming.apiKeyFile; 993 in '' 994 set -o errexit -o pipefail -o nounset -o errtrace 995 shopt -s inherit_errexit 996 997 api_key=$(<'${apiKeyPath}') 998 export api_key 999 1000 jq <${mail-receiver-json} \ 1001 '.DISCOURSE_API_KEY = $ENV.api_key' \ 1002 >'/run/discourse-mail-receiver/mail-receiver-environment.json' 1003 ''; 1004 1005 serviceConfig = { 1006 Type = "oneshot"; 1007 RemainAfterExit = true; 1008 RuntimeDirectory = "discourse-mail-receiver"; 1009 RuntimeDirectoryMode = "0700"; 1010 StateDirectory = "discourse-mail-receiver"; 1011 User = "discourse"; 1012 Group = "discourse"; 1013 }; 1014 }); 1015 1016 services.discourse.siteSettings = { 1017 required = { 1018 notification_email = cfg.mail.notificationEmailAddress; 1019 contact_email = cfg.mail.contactEmailAddress; 1020 }; 1021 security.force_https = tlsEnabled; 1022 email = { 1023 manual_polling_enabled = cfg.mail.incoming.enable; 1024 reply_by_email_enabled = cfg.mail.incoming.enable; 1025 reply_by_email_address = cfg.mail.incoming.replyEmailAddress; 1026 }; 1027 }; 1028 1029 services.postfix = lib.mkIf cfg.mail.incoming.enable { 1030 enable = true; 1031 sslCert = lib.optionalString (cfg.sslCertificate != null) cfg.sslCertificate; 1032 sslKey = lib.optionalString (cfg.sslCertificateKey != null) cfg.sslCertificateKey; 1033 1034 origin = cfg.hostname; 1035 relayDomains = [ cfg.hostname ]; 1036 config = { 1037 smtpd_recipient_restrictions = "check_policy_service unix:private/discourse-policy"; 1038 append_dot_mydomain = lib.mkDefault false; 1039 compatibility_level = "2"; 1040 smtputf8_enable = false; 1041 smtpd_banner = lib.mkDefault "ESMTP server"; 1042 myhostname = lib.mkDefault cfg.hostname; 1043 mydestination = lib.mkDefault "localhost"; 1044 }; 1045 transport = '' 1046 ${cfg.hostname} discourse-mail-receiver: 1047 ''; 1048 masterConfig = { 1049 "discourse-mail-receiver" = { 1050 type = "unix"; 1051 privileged = true; 1052 chroot = false; 1053 command = "pipe"; 1054 args = [ 1055 "user=discourse" 1056 "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/receive-mail" 1057 "\${recipient}" 1058 ]; 1059 }; 1060 "discourse-policy" = { 1061 type = "unix"; 1062 privileged = true; 1063 chroot = false; 1064 command = "spawn"; 1065 args = [ 1066 "user=discourse" 1067 "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/discourse-smtp-fast-rejection" 1068 ]; 1069 }; 1070 }; 1071 }; 1072 1073 users.users = { 1074 discourse = { 1075 group = "discourse"; 1076 isSystemUser = true; 1077 }; 1078 } // (lib.optionalAttrs cfg.nginx.enable { 1079 ${config.services.nginx.user}.extraGroups = [ "discourse" ]; 1080 }); 1081 1082 users.groups = { 1083 discourse = {}; 1084 }; 1085 1086 environment.systemPackages = [ 1087 cfg.package.rake 1088 ]; 1089 }; 1090 1091 meta.doc = ./discourse.md; 1092 meta.maintainers = [ lib.maintainers.talyz ]; 1093}