burrow/nixos/modules/burrow-zulip.nix
2026-04-19 14:48:16 -07:00

587 lines
19 KiB
Nix

{ 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" <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'zulip') THEN
CREATE ROLE zulip LOGIN;
END IF;
END
\$\$;
ALTER ROLE zulip WITH LOGIN PASSWORD '$db_password_sql';
SQL
chmod 0644 "$setup_sql"
${pkgs.util-linux}/bin/runuser -u postgres -- psql -v ON_ERROR_STOP=1 -f "$setup_sql"
'';
};
systemd.services.burrow-zulip-rabbitmq-bootstrap = {
description = "Bootstrap RabbitMQ user for Burrow Zulip";
after = [ "rabbitmq.service" ];
wants = [ "rabbitmq.service" ];
requiredBy = [ "burrow-zulip.service" ];
before = [ "burrow-zulip.service" ];
path = [
config.services.rabbitmq.package
pkgs.bash
pkgs.coreutils
pkgs.gawk
pkgs.gnugrep
];
serviceConfig = {
Type = "oneshot";
User = "root";
Group = "root";
};
script = ''
set -euo pipefail
rabbit_password="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.rabbitmqPasswordFile})"
export HOME=${config.services.rabbitmq.dataDir}
rabbitmqctl await_startup
if rabbitmqctl list_users -q | awk '{ print $1 }' | grep -qx zulip; then
rabbitmqctl change_password zulip "$rabbit_password"
else
rabbitmqctl add_user zulip "$rabbit_password"
fi
rabbitmqctl set_permissions -p / zulip '.*' '.*' '.*'
if rabbitmqctl list_users -q | awk '{ print $1 }' | grep -qx guest; then
rabbitmqctl delete_user guest
fi
'';
};
systemd.services.burrow-zulip-runtime = {
description = "Prepare Burrow Zulip compose and SAML runtime files";
after = [
"postgresql.service"
"redis-zulip.service"
"memcached.service"
"rabbitmq.service"
"burrow-zulip-postgres-bootstrap.service"
"burrow-zulip-rabbitmq-bootstrap.service"
"burrow-authentik-ready.service"
"burrow-authentik-zulip-saml.service"
"network-online.target"
];
wants = [
"postgresql.service"
"redis-zulip.service"
"memcached.service"
"rabbitmq.service"
"burrow-zulip-postgres-bootstrap.service"
"burrow-zulip-rabbitmq-bootstrap.service"
"burrow-authentik-ready.service"
"burrow-authentik-zulip-saml.service"
"network-online.target"
];
requiredBy = [ "burrow-zulip.service" ];
before = [ "burrow-zulip.service" ];
path = [
pkgs.bash
pkgs.coreutils
pkgs.curl
pkgs.python3
];
restartTriggers = [
composeFile
cfg.postgresPasswordFile
cfg.rabbitmqPasswordFile
cfg.redisPasswordFile
cfg.secretKeyFile
];
serviceConfig = {
Type = "oneshot";
User = "root";
Group = "root";
};
script = ''
set -euo pipefail
install -d -m 0755 ${lib.escapeShellArg cfg.dataDir}
install -d -m 0755 ${lib.escapeShellArg "${cfg.dataDir}/data"}
install -d -m 0755 ${lib.escapeShellArg "${cfg.dataDir}/data/logs"}
install -d -m 0755 ${lib.escapeShellArg "${cfg.dataDir}/data/logs/emails"}
install -d -m 0700 ${lib.escapeShellArg "${cfg.dataDir}/data/secrets"}
install -d -m 0700 ${lib.escapeShellArg "${cfg.dataDir}/secrets"}
install -d -m 0755 ${lib.escapeShellArg "${cfg.dataDir}/logs"}
install -m 0644 ${composeFile} ${lib.escapeShellArg "${cfg.dataDir}/compose.yaml"}
: > ${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"} <<EOF
secrets:
zulip__postgres_password:
file: ${cfg.postgresPasswordFile}
zulip__rabbitmq_password:
file: ${cfg.rabbitmqPasswordFile}
zulip__redis_password:
file: ${cfg.redisPasswordFile}
zulip__secret_key:
file: ${cfg.secretKeyFile}
zulip__email_password:
file: ${cfg.dataDir}/secrets/email-password
services:
zulip:
environment:
SETTING_EXTERNAL_HOST: "${cfg.domain}"
SETTING_ZULIP_ADMINISTRATOR: "${cfg.administratorEmail}"
TRUST_GATEWAY_IP: "True"
SETTING_SEND_LOGIN_EMAILS: "False"
ZULIP_AUTH_BACKENDS: "SAMLAuthBackend"
CONFIG_application_server__http_only: true
CONFIG_application_server__nginx_listen_port: ${toString cfg.port}
CONFIG_application_server__queue_workers_multiprocess: false
ZULIP_CUSTOM_SETTINGS: |
EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
EMAIL_FILE_PATH = "/data/logs/emails"
EXTERNAL_URI_SCHEME = "https://"
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = ["https://${cfg.domain}"]
SOCIAL_AUTH_REDIRECT_IS_HTTPS = True
SOCIAL_AUTH_SAML_REDIRECT_IS_HTTPS = True
SOCIAL_AUTH_SAML_SP_ENTITY_ID = "https://${cfg.domain}"
SOCIAL_AUTH_SAML_SP_EXTRA = {
"assertionConsumerService": {
"url": "https://${cfg.domain}/complete/saml/",
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
},
}
SOCIAL_AUTH_SAML_ORG_INFO = {
"en-US": {
"displayname": "Burrow Zulip",
"name": "zulip",
"url": "https://${cfg.domain}",
},
}
SOCIAL_AUTH_SAML_ENABLED_IDPS = {
"authentik": {
"entity_id": "https://${cfg.authentikDomain}",
"url": "https://${cfg.authentikDomain}/application/saml/${cfg.authentikProviderSlug}/sso/binding/redirect/",
"display_name": "burrow.net",
"auto_signup": True,
"extra_attrs": ["zulip_role"],
"x509cert": """$saml_cert""",
"attr_user_permanent_id": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
"attr_username": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
"attr_email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
"attr_first_name": "firstName",
"attr_last_name": "lastName",
},
}
SOCIAL_AUTH_SYNC_ATTRS_DICT = {
"": {
"saml": {
"role": "zulip_role",
},
},
}
EOF
'';
};
systemd.services.burrow-zulip = {
description = "Run Burrow Zulip with host-managed dependencies";
after = [
"burrow-zulip-runtime.service"
"network-online.target"
];
wants = [
"burrow-zulip-runtime.service"
"network-online.target"
];
wantedBy = [ "multi-user.target" ];
path = [
pkgs.bash
pkgs.coreutils
pkgs.gawk
pkgs.gnugrep
pkgs.openssl
pkgs.podman
pkgs.podman-compose
];
restartTriggers = [
composeFile
cfg.postgresPasswordFile
cfg.rabbitmqPasswordFile
cfg.redisPasswordFile
cfg.secretKeyFile
];
serviceConfig = {
Type = "oneshot";
User = "root";
Group = "root";
WorkingDirectory = cfg.dataDir;
RemainAfterExit = true;
TimeoutStopSec = "20s";
ExecStop = "${pkgs.bash}/bin/bash -lc 'set -euo pipefail; if ${pkgs.podman}/bin/podman container exists burrow-zulip_zulip_1; then ${pkgs.podman}/bin/podman stop --ignore --time 10 burrow-zulip_zulip_1 >/dev/null || true; ${pkgs.podman}/bin/podman rm -f --ignore burrow-zulip_zulip_1 >/dev/null || true; fi'";
};
script = ''
set -euo pipefail
cd ${lib.escapeShellArg cfg.dataDir}
compose() {
${pkgs.podman-compose}/bin/podman-compose -p burrow-zulip "$@"
}
ensure_zulip_data_layout() {
local zulip_data_dir=${lib.escapeShellArg "${cfg.dataDir}/data"}
install -d -m 0755 "$zulip_data_dir/logs"
install -d -m 0755 "$zulip_data_dir/logs/emails"
install -d -m 0700 "$zulip_data_dir/secrets"
chown 1000:1000 "$zulip_data_dir/logs" "$zulip_data_dir/logs/emails" "$zulip_data_dir/secrets"
if [ ! -s "$zulip_data_dir/secrets/bootstrap-owner-password" ]; then
umask 077
openssl rand -base64 24 > "$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
'';
};
};
}