{ config, lib, pkgs, ... }: let cfg = config.services.burrow.zulip; realmSignupDomain = let parts = lib.splitString "@" cfg.administratorEmail; in if builtins.length parts == 2 then builtins.elemAt parts 1 else cfg.domain; yamlFormat = pkgs.formats.yaml { }; composeFile = yamlFormat.generate "burrow-zulip-compose.yaml" { services = { zulip = { image = "ghcr.io/zulip/zulip-server:11.6-1"; restart = "unless-stopped"; network_mode = "host"; secrets = [ "zulip__postgres_password" "zulip__rabbitmq_password" "zulip__redis_password" "zulip__secret_key" "zulip__email_password" ]; environment = { SETTING_REMOTE_POSTGRES_HOST = "127.0.0.1"; SETTING_MEMCACHED_LOCATION = "127.0.0.1:11211"; SETTING_RABBITMQ_HOST = "127.0.0.1"; SETTING_REDIS_HOST = "127.0.0.1"; }; volumes = [ "${cfg.dataDir}/data:/data:rw" ]; ulimits.nofile = { soft = 1000000; hard = 1048576; }; }; }; }; in { options.services.burrow.zulip = { enable = lib.mkEnableOption "the Burrow Zulip deployment"; domain = lib.mkOption { type = lib.types.str; default = "chat.burrow.net"; description = "Public Zulip domain."; }; port = lib.mkOption { type = lib.types.port; default = 18090; description = "Local loopback port Caddy should proxy to."; }; dataDir = lib.mkOption { type = lib.types.str; default = "/var/lib/burrow/zulip"; description = "Host directory storing Zulip compose state and generated runtime files."; }; administratorEmail = lib.mkOption { type = lib.types.str; default = "contact@burrow.net"; description = "Operational Zulip administrator email."; }; realmName = lib.mkOption { type = lib.types.str; default = "Burrow"; description = "Initial Zulip organization name for single-tenant bootstrap."; }; realmOwnerName = lib.mkOption { type = lib.types.str; default = "Burrow"; description = "Display name used for the initial Zulip organization owner."; }; authentikDomain = lib.mkOption { type = lib.types.str; default = config.services.burrow.authentik.domain; description = "Authentik domain Zulip should trust as its SAML IdP."; }; authentikProviderSlug = lib.mkOption { type = lib.types.str; default = config.services.burrow.authentik.zulipProviderSlug; description = "Authentik SAML application slug used for Zulip."; }; postgresPasswordFile = lib.mkOption { type = lib.types.str; description = "File containing the Zulip PostgreSQL password."; }; rabbitmqPasswordFile = lib.mkOption { type = lib.types.str; description = "File containing the Zulip RabbitMQ password."; }; redisPasswordFile = lib.mkOption { type = lib.types.str; description = "File containing the Zulip Redis password."; }; secretKeyFile = lib.mkOption { type = lib.types.str; description = "File containing the Zulip Django secret key."; }; }; config = lib.mkIf cfg.enable { environment.systemPackages = [ pkgs.podman pkgs.podman-compose ]; services.postgresql = { ensureDatabases = [ "zulip" ]; ensureUsers = [ { name = "zulip"; ensureDBOwnership = true; } ]; settings = { listen_addresses = lib.mkDefault "127.0.0.1"; password_encryption = lib.mkDefault "scram-sha-256"; }; authentication = lib.mkAfter '' host zulip zulip 127.0.0.1/32 scram-sha-256 ''; }; services.postgresqlBackup = { enable = true; backupAll = false; databases = [ "zulip" ]; }; services.memcached = { enable = true; listen = "127.0.0.1"; port = 11211; extraOptions = [ "-U 0" ]; }; services.redis.servers.zulip = { enable = true; bind = "127.0.0.1"; port = 6379; requirePassFile = cfg.redisPasswordFile; }; services.rabbitmq = { enable = true; listenAddress = "127.0.0.1"; port = 5672; }; services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} ''; systemd.tmpfiles.rules = [ "d ${cfg.dataDir} 0755 root root - -" "d ${cfg.dataDir}/data 0755 root root - -" "d ${cfg.dataDir}/data/logs 0755 root root - -" "d ${cfg.dataDir}/data/logs/emails 0755 root root - -" "d ${cfg.dataDir}/data/secrets 0700 root root - -" "d ${cfg.dataDir}/secrets 0700 root root - -" "d ${cfg.dataDir}/logs 0755 root root - -" ]; systemd.services.burrow-zulip-postgres-bootstrap = { description = "Bootstrap PostgreSQL role for Burrow Zulip"; after = [ "postgresql.service" ]; wants = [ "postgresql.service" ]; requiredBy = [ "burrow-zulip.service" ]; before = [ "burrow-zulip.service" ]; path = [ config.services.postgresql.package pkgs.bash pkgs.coreutils pkgs.python3 pkgs.util-linux ]; serviceConfig = { Type = "oneshot"; User = "root"; Group = "root"; }; script = '' set -euo pipefail db_password="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.postgresPasswordFile})" db_password_sql="$(printf '%s' "$db_password" | python3 -c "import sys; print(sys.stdin.read().replace(chr(39), chr(39) * 2), end=\"\")")" setup_sql="$(mktemp)" trap 'rm -f "$setup_sql"' EXIT cat > "$setup_sql" < ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} chmod 0600 ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} metadata_xml="$(${pkgs.curl}/bin/curl -fsSL https://${cfg.authentikDomain}/application/saml/${cfg.authentikProviderSlug}/metadata/)" saml_cert="$(printf '%s' "$metadata_xml" | ${pkgs.python3}/bin/python3 -c ' import xml.etree.ElementTree as ET, sys xml = sys.stdin.read() root = ET.fromstring(xml) ns = {"ds": "http://www.w3.org/2000/09/xmldsig#"} node = root.find(".//ds:X509Certificate", ns) if node is None or not (node.text or "").strip(): raise SystemExit("missing X509 certificate in Authentik metadata") print((node.text or "").strip()) ')" cat > ${lib.escapeShellArg "${cfg.dataDir}/compose.override.yaml"} < "$zulip_data_dir/secrets/bootstrap-owner-password" fi chown 1000:1000 "$zulip_data_dir/secrets/bootstrap-owner-password" chmod 0600 "$zulip_data_dir/secrets/bootstrap-owner-password" } wait_for_zulip_supervisor() { local attempts=0 while ! podman exec burrow-zulip_zulip_1 supervisorctl status >/dev/null 2>&1; do attempts=$((attempts + 1)) if [ "$attempts" -ge 90 ]; then echo "error: Zulip supervisor did not become ready" >&2 exit 1 fi sleep 2 done } patch_uwsgi_scheme_handling() { wait_for_zulip_supervisor podman exec burrow-zulip_zulip_1 bash -lc "cat > /etc/nginx/zulip-include/trusted-proto <<'EOF' map \$remote_addr \$trusted_x_forwarded_proto { default \$scheme; 127.0.0.1 \$http_x_forwarded_proto; ::1 \$http_x_forwarded_proto; 172.31.1.1 \$http_x_forwarded_proto; } map \$remote_addr \$trusted_x_forwarded_for { default \"\"; 127.0.0.1 \$http_x_forwarded_for; ::1 \$http_x_forwarded_for; 172.31.1.1 \$http_x_forwarded_for; } map \$remote_addr \$x_proxy_misconfiguration { default \"\"; } EOF cat > /etc/nginx/uwsgi_params <<'EOF' uwsgi_param QUERY_STRING \$query_string; uwsgi_param REQUEST_METHOD \$request_method; uwsgi_param CONTENT_TYPE \$content_type; uwsgi_param CONTENT_LENGTH \$content_length; uwsgi_param REQUEST_URI \$request_uri; uwsgi_param PATH_INFO \$document_uri; uwsgi_param DOCUMENT_ROOT \$document_root; uwsgi_param SERVER_PROTOCOL \$server_protocol; uwsgi_param REQUEST_SCHEME \$trusted_x_forwarded_proto; uwsgi_param HTTPS on; uwsgi_param REMOTE_ADDR \$remote_addr; uwsgi_param REMOTE_PORT \$remote_port; uwsgi_param SERVER_ADDR \$server_addr; uwsgi_param SERVER_PORT \$server_port; uwsgi_param SERVER_NAME \$server_name; uwsgi_param HTTP_X_REAL_IP \$remote_addr; uwsgi_param HTTP_X_FORWARDED_PROTO \$trusted_x_forwarded_proto; uwsgi_param HTTP_X_FORWARDED_SSL \"\"; uwsgi_param HTTP_X_PROXY_MISCONFIGURATION \$x_proxy_misconfiguration; # This value is the default, and is provided for explicitness; it must # be longer than the configured 55s harakiri timeout in uwsgi uwsgi_read_timeout 60s; uwsgi_pass django; EOF supervisorctl restart nginx zulip-django >/dev/null" } bootstrap_realm_if_needed() { wait_for_zulip_supervisor local realm_exists realm_exists="$( podman exec burrow-zulip_zulip_1 bash -lc \ "su zulip -c '/home/zulip/deployments/current/manage.py list_realms'" \ | awk '$NF == "https://${cfg.domain}" { print "yes" }' )" if [ -n "$realm_exists" ]; then return 0 fi local realm_name=${lib.escapeShellArg cfg.realmName} local admin_email=${lib.escapeShellArg cfg.administratorEmail} local owner_name=${lib.escapeShellArg cfg.realmOwnerName} local create_realm_cmd printf -v create_realm_cmd '%q ' \ /home/zulip/deployments/current/manage.py \ create_realm \ --string-id= \ --password-file /data/secrets/bootstrap-owner-password \ --automated \ "$realm_name" \ "$admin_email" \ "$owner_name" podman exec burrow-zulip_zulip_1 su zulip -c "$create_realm_cmd" } reconcile_realm_policy() { wait_for_zulip_supervisor local realm_id realm_id="$( podman exec burrow-zulip_zulip_1 bash -lc \ "su zulip -c '/home/zulip/deployments/current/manage.py list_realms'" \ | awk '$NF == "https://${cfg.domain}" { print $1 }' )" podman exec burrow-zulip_zulip_1 su zulip -c \ "/home/zulip/deployments/current/manage.py realm_domain --op add -r $realm_id ${realmSignupDomain} --allow-subdomains --automated" \ >/dev/null 2>&1 || true podman exec burrow-zulip_zulip_1 su zulip -c \ "/home/zulip/deployments/current/manage.py shell -c 'from zerver.models import Realm; realm = Realm.objects.get(id=$realm_id); realm.invite_required = False; realm.save(update_fields=[\"invite_required\"])'" } if [ ! -e .initialized ]; then compose pull compose run --rm -T zulip app:init touch .initialized fi ensure_zulip_data_layout compose up -d zulip bootstrap_realm_if_needed reconcile_realm_policy patch_uwsgi_scheme_handling ''; }; }; }