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