Expose Tailscale and add Zulip SAML deployment

This commit is contained in:
Conrad Kramer 2026-04-19 00:13:10 -07:00
parent 7d3e7a6ec5
commit 44f437c33c
13 changed files with 1064 additions and 3 deletions

View file

@ -61,6 +61,7 @@ in
self.nixosModules.burrow-forgejo-nsc
self.nixosModules.burrow-authentik
self.nixosModules.burrow-headscale
self.nixosModules.burrow-zulip
];
system.stateVersion = "24.11";
@ -162,9 +163,44 @@ in
mode = "0400";
};
age.secrets.burrowZulipPostgresPassword = {
file = ../../../secrets/infra/zulip-postgres-password.age;
owner = "root";
group = "root";
mode = "0400";
};
age.secrets.burrowZulipMemcachedPassword = {
file = ../../../secrets/infra/zulip-memcached-password.age;
owner = "root";
group = "root";
mode = "0400";
};
age.secrets.burrowZulipRabbitmqPassword = {
file = ../../../secrets/infra/zulip-rabbitmq-password.age;
owner = "root";
group = "root";
mode = "0400";
};
age.secrets.burrowZulipRedisPassword = {
file = ../../../secrets/infra/zulip-redis-password.age;
owner = "root";
group = "root";
mode = "0400";
};
age.secrets.burrowZulipSecretKey = {
file = ../../../secrets/infra/zulip-secret-key.age;
owner = "root";
group = "root";
mode = "0400";
};
networking.extraHosts = ''
127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net
::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net
127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net chat.burrow.net nsc-autoscaler.burrow.net
::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net chat.burrow.net nsc-autoscaler.burrow.net
'';
services.burrow.forge = {
@ -208,6 +244,8 @@ in
forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path;
headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path;
tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path;
tailscaleAccessGroupName = contributors.groups.users;
defaultExternalApplicationSlug = "tailscale";
googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path;
googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path;
googleAccountMapFile = config.age.secrets.burrowAuthentikGoogleAccountMap.path;
@ -224,6 +262,7 @@ in
linearOwnerGroupName = linearGroups.owners;
linearAdminGroupName = linearGroups.admins;
linearGuestGroupName = linearGroups.guests;
zulipAccessGroupName = contributors.groups.users;
};
services.burrow.headscale = {
@ -231,4 +270,14 @@ in
oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path;
bootstrapUsers = headscaleBootstrapUsers;
};
services.burrow.zulip = {
enable = true;
administratorEmail = identities.contact.canonicalEmail;
postgresPasswordFile = config.age.secrets.burrowZulipPostgresPassword.path;
memcachedPasswordFile = config.age.secrets.burrowZulipMemcachedPassword.path;
rabbitmqPasswordFile = config.age.secrets.burrowZulipRabbitmqPassword.path;
redisPasswordFile = config.age.secrets.burrowZulipRedisPassword.path;
secretKeyFile = config.age.secrets.burrowZulipSecretKey.path;
};
}

View file

@ -12,6 +12,7 @@ let
forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh;
tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh;
onePasswordOidcSyncScript = ../../Scripts/authentik-sync-1password-oidc.sh;
zulipSamlSyncScript = ../../Scripts/authentik-sync-zulip-saml.sh;
linearSamlSyncScript = ../../Scripts/authentik-sync-linear-saml.sh;
linearScimSyncScript = ../../Scripts/authentik-sync-linear-scim.sh;
googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh;
@ -153,6 +154,18 @@ in
description = "Host-local file containing the Authentik Tailscale OIDC client secret.";
};
tailscaleAccessGroupName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Authentik group that should be allowed to launch the Tailscale application.";
};
defaultExternalApplicationSlug = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Authentik application slug that external users should land on instead of /if/user/.";
};
onePasswordDomain = lib.mkOption {
type = lib.types.str;
default = "burrow-team.1password.com";
@ -186,6 +199,42 @@ in
description = "Authentik application slug for Linear SAML.";
};
zulipDomain = lib.mkOption {
type = lib.types.str;
default = "chat.burrow.net";
description = "Public Zulip domain exposed through Authentik SAML.";
};
zulipProviderSlug = lib.mkOption {
type = lib.types.str;
default = "zulip";
description = "Authentik application slug for Zulip SAML.";
};
zulipAcsUrl = lib.mkOption {
type = lib.types.str;
default = "https://${config.services.burrow.authentik.zulipDomain}/complete/saml/";
description = "Zulip SAML ACS URL.";
};
zulipAudience = lib.mkOption {
type = lib.types.str;
default = "https://${config.services.burrow.authentik.zulipDomain}";
description = "Zulip SAML audience/entity identifier.";
};
zulipLaunchUrl = lib.mkOption {
type = lib.types.str;
default = "https://${config.services.burrow.authentik.zulipDomain}/";
description = "Zulip URL exposed in Authentik.";
};
zulipAccessGroupName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Authentik group allowed to launch Zulip from Burrow SSO surfaces.";
};
linearAcsUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
@ -809,6 +858,12 @@ EOF
export AUTHENTIK_TAILSCALE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.tailscaleClientSecretFile})"
export AUTHENTIK_TAILSCALE_LAUNCH_URL=https://login.tailscale.com/start/oidc
export AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON='["https://login.tailscale.com/a/oauth_response"]'
${lib.optionalString (cfg.tailscaleAccessGroupName != null) ''
export AUTHENTIK_TAILSCALE_ACCESS_GROUP=${lib.escapeShellArg cfg.tailscaleAccessGroupName}
''}
${lib.optionalString (cfg.defaultExternalApplicationSlug != null) ''
export AUTHENTIK_DEFAULT_EXTERNAL_APPLICATION_SLUG=${lib.escapeShellArg cfg.defaultExternalApplicationSlug}
''}
${pkgs.bash}/bin/bash ${tailscaleOidcSyncScript}
'';
@ -859,6 +914,53 @@ EOF
'';
};
systemd.services.burrow-authentik-zulip-saml = {
description = "Reconcile the Burrow Authentik Zulip SAML application";
after = [
"burrow-authentik-ready.service"
"network-online.target"
];
wants = [
"burrow-authentik-ready.service"
"network-online.target"
];
wantedBy = [ "multi-user.target" ];
restartTriggers = [
zulipSamlSyncScript
cfg.envFile
];
path = [
pkgs.bash
pkgs.coreutils
pkgs.curl
pkgs.jq
];
serviceConfig = {
Type = "oneshot";
User = "root";
Group = "root";
};
script = ''
set -euo pipefail
set -a
source ${lib.escapeShellArg cfg.envFile}
set +a
export AUTHENTIK_URL=https://${cfg.domain}
export AUTHENTIK_ZULIP_APPLICATION_SLUG=${lib.escapeShellArg cfg.zulipProviderSlug}
export AUTHENTIK_ZULIP_APPLICATION_NAME=Zulip
export AUTHENTIK_ZULIP_PROVIDER_NAME=Zulip
export AUTHENTIK_ZULIP_ACS_URL=${lib.escapeShellArg cfg.zulipAcsUrl}
export AUTHENTIK_ZULIP_AUDIENCE=${lib.escapeShellArg cfg.zulipAudience}
export AUTHENTIK_ZULIP_LAUNCH_URL=${lib.escapeShellArg cfg.zulipLaunchUrl}
${lib.optionalString (cfg.zulipAccessGroupName != null) ''
export AUTHENTIK_ZULIP_ACCESS_GROUP=${lib.escapeShellArg cfg.zulipAccessGroupName}
''}
${pkgs.bash}/bin/bash ${zulipSamlSyncScript}
'';
};
systemd.services.burrow-authentik-linear-saml = lib.mkIf (
cfg.linearAcsUrl != null && cfg.linearAudience != null
) {

View file

@ -0,0 +1,354 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.burrow.zulip;
yamlFormat = pkgs.formats.yaml { };
composeFile = yamlFormat.generate "burrow-zulip-compose.yaml" {
services = {
database = {
image = "zulip/zulip-postgresql:14";
restart = "unless-stopped";
secrets = [ "zulip__postgres_password" ];
environment = {
POSTGRES_DB = "zulip";
POSTGRES_USER = "zulip";
POSTGRES_PASSWORD_FILE = "/run/secrets/zulip__postgres_password";
};
volumes = [ "postgresql-14:/var/lib/postgresql/data:rw" ];
attach = false;
};
memcached = {
image = "memcached:alpine";
restart = "unless-stopped";
command = [
"sh"
"-euc"
''
echo 'mech_list: plain' > "$SASL_CONF_PATH"
echo "zulip@$HOSTNAME:$(cat $MEMCACHED_PASSWORD_FILE)" > "$MEMCACHED_SASL_PWDB"
echo "zulip@localhost:$(cat $MEMCACHED_PASSWORD_FILE)" >> "$MEMCACHED_SASL_PWDB"
exec memcached -S
''
];
secrets = [ "zulip__memcached_password" ];
environment = {
SASL_CONF_PATH = "/home/memcache/memcached.conf";
MEMCACHED_SASL_PWDB = "/home/memcache/memcached-sasl-db";
MEMCACHED_PASSWORD_FILE = "/run/secrets/zulip__memcached_password";
};
attach = false;
};
rabbitmq = {
image = "rabbitmq:4.2";
restart = "unless-stopped";
command = [
"sh"
"-euc"
''
export RABBITMQ_DEFAULT_PASS="$(cat "$RABBITMQ_PASSWORD_FILE")"
echo "default_user = $RABBITMQ_DEFAULT_USER" >> /etc/rabbitmq/rabbitmq.conf
echo "default_pass = $RABBITMQ_DEFAULT_PASS" >> /etc/rabbitmq/rabbitmq.conf
exec docker-entrypoint.sh rabbitmq-server
''
];
secrets = [ "zulip__rabbitmq_password" ];
environment = {
RABBITMQ_DEFAULT_USER = "zulip";
RABBITMQ_PASSWORD_FILE = "/run/secrets/zulip__rabbitmq_password";
};
volumes = [ "rabbitmq:/var/lib/rabbitmq:rw" ];
attach = false;
};
redis = {
image = "redis:alpine";
restart = "unless-stopped";
command = [
"sh"
"-euc"
"/usr/local/bin/docker-entrypoint.sh --requirepass \"$(cat \"$REDIS_PASSWORD_FILE\")\""
];
secrets = [ "zulip__redis_password" ];
environment = {
REDIS_PASSWORD_FILE = "/run/secrets/zulip__redis_password";
};
volumes = [ "redis:/data:rw" ];
attach = false;
};
zulip = {
image = "ghcr.io/zulip/zulip-server:11.6-1";
restart = "unless-stopped";
secrets = [
"zulip__postgres_password"
"zulip__memcached_password"
"zulip__rabbitmq_password"
"zulip__redis_password"
"zulip__secret_key"
"zulip__email_password"
];
environment = {
SETTING_REMOTE_POSTGRES_HOST = "database";
SETTING_MEMCACHED_LOCATION = "memcached:11211";
SETTING_RABBITMQ_HOST = "rabbitmq";
SETTING_REDIS_HOST = "redis";
};
volumes = [ "zulip:/data:rw" ];
ulimits.nofile = {
soft = 1000000;
hard = 1048576;
};
depends_on = [
"database"
"memcached"
"rabbitmq"
"redis"
];
};
};
volumes = {
zulip = { };
postgresql-14 = { };
rabbitmq = { };
redis = { };
};
};
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.";
};
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.";
};
memcachedPasswordFile = lib.mkOption {
type = lib.types.str;
description = "File containing the Zulip memcached 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.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}/secrets 0700 root root - -"
"d ${cfg.dataDir}/logs 0755 root root - -"
];
systemd.services.burrow-zulip-runtime = {
description = "Prepare Burrow Zulip compose and SAML runtime files";
after = [
"burrow-authentik-ready.service"
"burrow-authentik-zulip-saml.service"
"network-online.target"
];
wants = [
"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.memcachedPasswordFile
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 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 -fsS https://${cfg.authentikDomain}/application/saml/${cfg.authentikProviderSlug}/metadata/)"
saml_cert="$(printf '%s' "$metadata_xml" | ${pkgs.python3}/bin/python3 -c '
import re, sys, xml.etree.ElementTree as ET
xml = sys.stdin.read()
root = ET.fromstring(xml)
ns = {"md": "urn:oasis:names:tc:SAML:2.0:metadata", "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__memcached_password:
file: ${cfg.memcachedPasswordFile}
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:
ports:
- "127.0.0.1:${toString cfg.port}:80"
environment:
SETTING_EXTERNAL_HOST: "${cfg.domain}"
SETTING_ZULIP_ADMINISTRATOR: "${cfg.administratorEmail}"
TRUST_GATEWAY_IP: "True"
SETTING_SEND_LOGIN_EMAILS: "False"
ZULIP_AUTH_BACKENDS: "EmailAuthBackend,SAMLAuthBackend"
ZULIP_CUSTOM_SETTINGS: |
EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
EMAIL_FILE_PATH = "/data/logs/emails"
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",
"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",
},
}
CONFIG_application_server__queue_workers_multiprocess: false
EOF
'';
};
systemd.services.burrow-zulip = {
description = "Run Burrow Zulip via the official compose topology";
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.podman
pkgs.podman-compose
];
restartTriggers = [
composeFile
cfg.postgresPasswordFile
cfg.memcachedPasswordFile
cfg.rabbitmqPasswordFile
cfg.redisPasswordFile
cfg.secretKeyFile
];
serviceConfig = {
Type = "oneshot";
User = "root";
Group = "root";
WorkingDirectory = cfg.dataDir;
RemainAfterExit = true;
ExecStop = "${pkgs.bash}/bin/bash -lc 'cd ${lib.escapeShellArg cfg.dataDir} && ${pkgs.podman-compose}/bin/podman-compose -p burrow-zulip down'";
};
script = ''
set -euo pipefail
cd ${lib.escapeShellArg cfg.dataDir}
if [ ! -e .initialized ]; then
${pkgs.podman-compose}/bin/podman-compose -p burrow-zulip pull
${pkgs.podman-compose}/bin/podman-compose -p burrow-zulip run --rm zulip app:init
touch .initialized
fi
${pkgs.podman-compose}/bin/podman-compose -p burrow-zulip up -d
'';
};
};
}