From 44f437c33c9d2ee7f1171e070955d148824a3041 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:13:10 -0700 Subject: [PATCH 01/14] Expose Tailscale and add Zulip SAML deployment --- Scripts/authentik-sync-tailscale-oidc.sh | 103 +++++ Scripts/authentik-sync-zulip-saml.sh | 398 ++++++++++++++++++ ...ntik-backed-team-chat-and-workspace-sso.md | 7 +- flake.nix | 1 + nixos/hosts/burrow-forge/default.nix | 53 ++- nixos/modules/burrow-authentik.nix | 102 +++++ nixos/modules/burrow-zulip.nix | 354 ++++++++++++++++ secrets.nix | 5 + secrets/infra/zulip-memcached-password.age | 11 + secrets/infra/zulip-postgres-password.age | Bin 0 -> 578 bytes secrets/infra/zulip-rabbitmq-password.age | 11 + secrets/infra/zulip-redis-password.age | 11 + secrets/infra/zulip-secret-key.age | 11 + 13 files changed, 1064 insertions(+), 3 deletions(-) create mode 100644 Scripts/authentik-sync-zulip-saml.sh create mode 100644 nixos/modules/burrow-zulip.nix create mode 100644 secrets/infra/zulip-memcached-password.age create mode 100644 secrets/infra/zulip-postgres-password.age create mode 100644 secrets/infra/zulip-rabbitmq-password.age create mode 100644 secrets/infra/zulip-redis-password.age create mode 100644 secrets/infra/zulip-secret-key.age diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh index 54564ad..9e01b97 100755 --- a/Scripts/authentik-sync-tailscale-oidc.sh +++ b/Scripts/authentik-sync-tailscale-oidc.sh @@ -10,6 +10,8 @@ template_slug="${AUTHENTIK_TAILSCALE_TEMPLATE_SLUG:-ts}" client_id="${AUTHENTIK_TAILSCALE_CLIENT_ID:-tailscale.burrow.net}" client_secret="${AUTHENTIK_TAILSCALE_CLIENT_SECRET:-}" launch_url="${AUTHENTIK_TAILSCALE_LAUNCH_URL:-https://login.tailscale.com/start/oidc}" +access_group="${AUTHENTIK_TAILSCALE_ACCESS_GROUP:-}" +default_external_application_slug="${AUTHENTIK_DEFAULT_EXTERNAL_APPLICATION_SLUG:-}" redirect_uris_json="${AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON:-[ \"https://login.tailscale.com/a/oauth_response\" ]}" @@ -31,6 +33,8 @@ Optional environment: AUTHENTIK_TAILSCALE_CLIENT_ID AUTHENTIK_TAILSCALE_LAUNCH_URL AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON + AUTHENTIK_TAILSCALE_ACCESS_GROUP + AUTHENTIK_DEFAULT_EXTERNAL_APPLICATION_SLUG EOF } @@ -123,6 +127,97 @@ wait_for_authentik() { wait_for_authentik +lookup_group_pk() { + local group_name="$1" + + api GET "/api/v3/core/groups/?page_size=200" \ + | jq -r --arg group_name "$group_name" '.results[]? | select(.name == $group_name) | .pk // empty' \ + | head -n1 +} + +lookup_application_pk() { + local slug="$1" + + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ + | head -n1 +} + +ensure_application_group_binding() { + local application_slug="$1" + local group_name="$2" + local application_pk group_pk existing payload binding_pk + + application_pk="$(lookup_application_pk "$application_slug")" + if [[ -z "$application_pk" ]]; then + echo "warning: could not resolve Authentik application ${application_slug}; skipping application group binding" >&2 + return 0 + fi + + group_pk="$(lookup_group_pk "$group_name")" + if [[ -z "$group_pk" ]]; then + echo "error: could not resolve Authentik group ${group_name}" >&2 + exit 1 + fi + + existing="$( + api GET "/api/v3/policies/bindings/?page_size=200&target=${application_pk}" \ + | jq -c --arg group_pk "$group_pk" '.results[]? | select(.group == $group_pk)' \ + | head -n1 + )" + + payload="$( + jq -cn \ + --arg target "$application_pk" \ + --arg group "$group_pk" \ + '{ + group: $group, + target: $target, + negate: false, + enabled: true, + order: 100, + timeout: 30, + failure_result: false + }' + )" + + if [[ -n "$existing" ]]; then + binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/policies/bindings/${binding_pk}/" "$payload" >/dev/null + else + api POST "/api/v3/policies/bindings/" "$payload" >/dev/null + fi +} + +ensure_default_external_application() { + local application_slug="$1" + local application_pk default_brand brand_payload + + application_pk="$(lookup_application_pk "$application_slug")" + if [[ -z "$application_pk" ]]; then + echo "error: could not resolve Authentik application ${application_slug} for brand default application" >&2 + exit 1 + fi + + default_brand="$( + api GET "/api/v3/core/brands/?page_size=200" \ + | jq -c '.results[]? | select(.default == true)' \ + | head -n1 + )" + + if [[ -z "$default_brand" ]]; then + echo "warning: could not resolve the default Authentik brand; skipping external default application" >&2 + return 0 + fi + + brand_payload="$( + printf '%s\n' "$default_brand" \ + | jq --arg application_pk "$application_pk" '.default_application = $application_pk' + )" + + api PUT "/api/v3/core/brands/$(printf '%s\n' "$default_brand" | jq -r '.brand_uuid')/" "$brand_payload" >/dev/null +} + template_provider="$( api GET "/api/v3/providers/oauth2/?page_size=200" \ | jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \ @@ -239,6 +334,14 @@ if [[ -z "${application_pk:-}" ]]; then exit 1 fi +if [[ -n "$access_group" ]]; then + ensure_application_group_binding "$application_slug" "$access_group" +fi + +if [[ -n "$default_external_application_slug" ]]; then + ensure_default_external_application "$default_external_application_slug" +fi + for _ in $(seq 1 30); do if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then echo "Synced Authentik Tailscale OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/authentik-sync-zulip-saml.sh b/Scripts/authentik-sync-zulip-saml.sh new file mode 100644 index 0000000..d503ce0 --- /dev/null +++ b/Scripts/authentik-sync-zulip-saml.sh @@ -0,0 +1,398 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_ZULIP_APPLICATION_SLUG:-zulip}" +application_name="${AUTHENTIK_ZULIP_APPLICATION_NAME:-Zulip}" +provider_name="${AUTHENTIK_ZULIP_PROVIDER_NAME:-Zulip}" +acs_url="${AUTHENTIK_ZULIP_ACS_URL:-https://chat.burrow.net/complete/saml/}" +audience="${AUTHENTIK_ZULIP_AUDIENCE:-https://chat.burrow.net}" +launch_url="${AUTHENTIK_ZULIP_LAUNCH_URL:-https://chat.burrow.net/}" +access_group="${AUTHENTIK_ZULIP_ACCESS_GROUP:-}" +issuer="${AUTHENTIK_ZULIP_ISSUER:-$authentik_url}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-zulip-saml.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_ZULIP_APPLICATION_SLUG + AUTHENTIK_ZULIP_APPLICATION_NAME + AUTHENTIK_ZULIP_PROVIDER_NAME + AUTHENTIK_ZULIP_ACS_URL + AUTHENTIK_ZULIP_AUDIENCE + AUTHENTIK_ZULIP_LAUNCH_URL + AUTHENTIK_ZULIP_ACCESS_GROUP + AUTHENTIK_ZULIP_ISSUER +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +api_with_status() { + local method="$1" + local path="$2" + local data="${3:-}" + local response_file status + + response_file="$(mktemp)" + trap 'rm -f "$response_file"' RETURN + + if [[ -n "$data" ]]; then + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + )" + else + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + )" + fi + + printf '%s\n' "$status" + cat "$response_file" +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +lookup_oauth_template_field() { + local field="$1" + + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -r --arg field "$field" '.results[]? | select(.assigned_application_slug == "ts") | .[$field]' \ + | head -n1 +} + +lookup_group_pk() { + local group_name="$1" + + api GET "/api/v3/core/groups/?page_size=200" \ + | jq -r --arg group_name "$group_name" '.results[]? | select(.name == $group_name) | .pk // empty' \ + | head -n1 +} + +lookup_application_pk() { + local slug="$1" + + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ + | head -n1 +} + +ensure_application_group_binding() { + local application_slug="$1" + local group_name="$2" + local application_pk group_pk existing payload binding_pk + + application_pk="$(lookup_application_pk "$application_slug")" + if [[ -z "$application_pk" ]]; then + echo "warning: could not resolve Authentik application ${application_slug}; skipping application group binding" >&2 + return 0 + fi + + group_pk="$(lookup_group_pk "$group_name")" + if [[ -z "$group_pk" ]]; then + echo "error: could not resolve Authentik group ${group_name}" >&2 + exit 1 + fi + + existing="$( + api GET "/api/v3/policies/bindings/?page_size=200&target=${application_pk}" \ + | jq -c --arg group_pk "$group_pk" '.results[]? | select(.group == $group_pk)' \ + | head -n1 + )" + + payload="$( + jq -cn \ + --arg target "$application_pk" \ + --arg group "$group_pk" \ + '{ + group: $group, + target: $target, + negate: false, + enabled: true, + order: 100, + timeout: 30, + failure_result: false + }' + )" + + if [[ -n "$existing" ]]; then + binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/policies/bindings/${binding_pk}/" "$payload" >/dev/null + else + api POST "/api/v3/policies/bindings/" "$payload" >/dev/null + fi +} + +reconcile_property_mapping() { + local name="$1" + local saml_name="$2" + local friendly_name="$3" + local expression="$4" + local payload existing_pk + + payload="$( + jq -n \ + --arg name "$name" \ + --arg saml_name "$saml_name" \ + --arg friendly_name "$friendly_name" \ + --arg expression "$expression" \ + '{ + name: $name, + saml_name: $saml_name, + friendly_name: $friendly_name, + expression: $expression + }' + )" + + existing_pk="$( + api GET "/api/v3/propertymappings/provider/saml/?page_size=200" \ + | jq -r --arg name "$name" '.results[]? | select(.name == $name) | .pk' \ + | head -n1 + )" + + if [[ -n "$existing_pk" ]]; then + api PATCH "/api/v3/propertymappings/provider/saml/${existing_pk}/" "$payload" >/dev/null + printf '%s\n' "$existing_pk" + else + api POST "/api/v3/propertymappings/provider/saml/" "$payload" | jq -r '.pk // empty' + fi +} + +wait_for_authentik + +authorization_flow="$(lookup_oauth_template_field authorization_flow)" +invalidation_flow="$(lookup_oauth_template_field invalidation_flow)" +signing_kp="$(lookup_oauth_template_field signing_key)" + +if [[ -z "$authorization_flow" || -z "$invalidation_flow" || -z "$signing_kp" ]]; then + echo "error: could not resolve Authentik provider defaults from Burrow Tailnet template" >&2 + exit 1 +fi + +email_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Zulip SAML Email" \ + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" \ + "email" \ + 'return request.user.email' +)" + +name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Zulip SAML Name" \ + "name" \ + "name" \ + 'return request.user.name or request.user.username' +)" + +first_name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Zulip SAML First Name" \ + "firstName" \ + "firstName" \ + $'parts = (request.user.name or "").split(" ", 1)\nif len(parts) > 0 and parts[0]:\n return parts[0]\nreturn request.user.username' +)" + +last_name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Zulip SAML Last Name" \ + "lastName" \ + "lastName" \ + $'parts = (request.user.name or "").rsplit(" ", 1)\nif len(parts) == 2 and parts[1]:\n return parts[1]\nreturn request.user.username' +)" + +if [[ -z "$email_mapping_pk" || -z "$name_mapping_pk" || -z "$first_name_mapping_pk" || -z "$last_name_mapping_pk" ]]; then + echo "error: failed to reconcile Zulip SAML property mappings" >&2 + exit 1 +fi + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg authorization_flow "$authorization_flow" \ + --arg invalidation_flow "$invalidation_flow" \ + --arg acs_url "$acs_url" \ + --arg audience "$audience" \ + --arg issuer "$issuer" \ + --arg signing_kp "$signing_kp" \ + --arg name_id_mapping "$email_mapping_pk" \ + --arg email_mapping "$email_mapping_pk" \ + --arg name_mapping "$name_mapping_pk" \ + --arg first_name_mapping "$first_name_mapping_pk" \ + --arg last_name_mapping "$last_name_mapping_pk" \ + '{ + name: $name, + authorization_flow: $authorization_flow, + invalidation_flow: $invalidation_flow, + acs_url: $acs_url, + audience: $audience, + issuer: $issuer, + signing_kp: $signing_kp, + sign_assertion: true, + sign_response: true, + sp_binding: "post", + name_id_mapping: $name_id_mapping, + property_mappings: [ + $email_mapping, + $name_mapping, + $first_name_mapping, + $last_name_mapping + ] + }' +)" + +existing_provider="$( + api GET "/api/v3/providers/saml/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +)" + +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/saml/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/saml/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: Zulip SAML provider did not return a primary key" >&2 + exit 1 +fi + +application_payload="$( + jq -n \ + --arg name "$application_name" \ + --arg slug "$application_slug" \ + --arg provider "$provider_pk" \ + --arg launch_url "$launch_url" \ + '{ + name: $name, + slug: $slug, + provider: ($provider | tonumber), + meta_launch_url: $launch_url, + open_in_new_tab: true, + policy_engine_mode: "any" + }' +)" + +existing_application="$( + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ + | head -n1 +)" + +if [[ -n "$existing_application" ]]; then + application_pk="existing" + api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null +else + create_application_result="$( + api_with_status POST "/api/v3/core/applications/" "$application_payload" + )" + create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" + create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" + + if [[ "$create_application_status" =~ ^20[01]$ ]]; then + application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" + elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' + (.slug // [] | index("Application with this slug already exists.")) != null + or (.provider // [] | index("Application with this provider already exists.")) != null + ' >/dev/null; then + application_pk="existing-duplicate" + else + printf '%s\n' "$create_application_body" >&2 + echo "error: could not reconcile Authentik application ${application_slug}" >&2 + exit 1 + fi +fi + +if [[ -z "${application_pk:-}" ]]; then + echo "error: Zulip SAML application did not return a primary key" >&2 + exit 1 +fi + +if [[ -n "$access_group" ]]; then + ensure_application_group_binding "$application_slug" "$access_group" +fi + +for _ in $(seq 1 30); do + metadata_status="$( + curl -sS \ + -o /dev/null \ + -w '%{http_code}' \ + --max-redirs 0 \ + "${authentik_url}/application/saml/${application_slug}/metadata/" \ + || true + )" + case "$metadata_status" in + 200|301|302|307|308) + echo "Synced Authentik Zulip SAML application ${application_slug} (${application_name})." + exit 0 + ;; + esac + sleep 2 +done + +echo "warning: Zulip SAML metadata for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik Zulip SAML application ${application_slug} (${application_name})." diff --git a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md index 63e0994..ff6e63d 100644 --- a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md +++ b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md @@ -68,6 +68,9 @@ across vendor-native Google auth flows when Burrow already operates an IdP. options instead of hand-edited UI state. - Prefer service-specific reconciliation over ad hoc manual setup so rebuilds and host replacement converge automatically. +- When Burrow wants an external-user launcher surface in Authentik, configure + the brand's `default_application` explicitly instead of relying on + `/if/user/`, which otherwise remains internal-user-only. - Derive Linear SCIM role groups from Burrow's canonical identity metadata. If Burrow-wide admin intent says a user is an operator/admin, the repo-owned configuration should map that intent onto the Linear push group without a @@ -111,8 +114,10 @@ across vendor-native Google auth flows when Burrow already operates an IdP. - Verify: - `chat.burrow.net` serves a working Zulip login surface - Authentik exposes working metadata for Zulip and Linear - - Authentik exposes a working OIDC issuer for 1Password +- Authentik exposes a working OIDC issuer for 1Password - users in Burrow admin groups receive the expected access on first login + - external Burrow users landing on `auth.burrow.net` reach the intended + app launcher target instead of the internal-only Authentik user interface - Record concrete evidence for: - host deployment generation - Authentik reconciliation success diff --git a/flake.nix b/flake.nix index 1974f17..e842fba 100644 --- a/flake.nix +++ b/flake.nix @@ -214,6 +214,7 @@ nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default; nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix; nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix; + nixosModules.burrow-zulip = import ./nixos/modules/burrow-zulip.nix; nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; specialArgs = { diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 0121f92..2d943b9 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -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; + }; } diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 772adc4..acf76ce 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -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 ) { diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix new file mode 100644 index 0000000..0fcad65 --- /dev/null +++ b/nixos/modules/burrow-zulip.nix @@ -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"} < ssh-ed25519 ux4N8Q x0r1UHgSibFIvKU34kP0+mnvQa5xXnac3P5fyqb7qFc +MfKnr5N0DV2NIoo4MFVFV0ULMayy0zzZqIq4FDzgDGc +-> ssh-ed25519 IrZmAg rzoR8knGrsTGuh9Hqg/NB0NQKI1vx1WI0ZRyrLIPwVY +7gV/d1slrIT+W0+iX5YK/uUWjHGJfee6vA+f9a35nEY +-> ssh-ed25519 0kWPgQ SyuEAfqmBAqLcuuQUHM5OzAv2hoquMMYtVdbKpBVhjI +7QqXens2363ln0euoormMh9a3Csh+nS2eBkHuQJmOWc +-> X25519 qDjNNkYBUhWTYyBhrw9tYl8a7G6TCkVZbR4aPcP+J0c +QF33V6hFUuYRj0B8Eo4jqyyvCpBbpD2ViVWoS8A8f3E +--- 1/Jb0nvWlcszMmxI0yVr6kfexDN0sSk1p+wsTUL4WvU +9a5IكV[f,Db \v&LZ7!?4=JxFeV \ No newline at end of file diff --git a/secrets/infra/zulip-postgres-password.age b/secrets/infra/zulip-postgres-password.age new file mode 100644 index 0000000000000000000000000000000000000000..b03556c4933321c6bf91d07806dbe4b15de64ad8 GIT binary patch literal 578 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCSHtuXPk2vqPXOG^vT z&v#A9cgZizF)T~5@bjoN&G#xS)pqkv@yn~IDh~-sb4qaxP3Ou8^3O~%2?@&faLhLk zi^@xNcXSL4vkdfgb}=(cP1Uwc)XwxaaCQkQC`Px*r?dm+|@HGJF`3~E0C)yxWv-QIK#lLz{RgPCC|??IlDO9 zt+c|_FgGOIxh%25EXCNdDA~*;%mCdsgY58t^gsoRjN;T>i!|c`<78jgq%sS)2-mz^ zeOGtqa>sD(+|u+UbC-$=^W;dsJYTL5SD%0|H)kjPC=a)&>;Uu1@-%Os;)2q&;^LfQ z%fRqLqw+$pDDU#5D6romAc2@tkm#D?UQiialHqIM?53}u?`0Bd7H*vD8c|ge5Ss4g z80K6YX;z#WT+U?@9`2QHkmBTB9uyei6)G0tE-TvUE$*AX&6{ulH`+|o0gwlQJj*O?`ohQR-9#Skee8pn39{B9-dJikjz!7 z+$v`jnKG|-&4fEsm&kZNUTwTeJL2$P#TUEhe6?Hk^}FQDm11@;jmvGOPj>#s=pfWm ky+teJ>QedHe>|S0GaPyDm^lCG5n0EANiN~rbwwWn07|vS`2YX_ literal 0 HcmV?d00001 diff --git a/secrets/infra/zulip-rabbitmq-password.age b/secrets/infra/zulip-rabbitmq-password.age new file mode 100644 index 0000000..9b1f6ec --- /dev/null +++ b/secrets/infra/zulip-rabbitmq-password.age @@ -0,0 +1,11 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q s1hLIWvkXmlIv/VeHXpDSCe+dh09mE+iZd7xJiQccy0 +8WosTJQLGRPhTR06SIDjgtXNebcf+H/pFzY/lBCjXcs +-> ssh-ed25519 IrZmAg zBNlK+o/RCTCyp8BRkoAYqsDn//kIKtYk3SICkMu3BA +EhBQy8QdSnCZKkdGzQho7zEMmAbJVoU5jZOMPN6tHG0 +-> ssh-ed25519 0kWPgQ hv06idPXqAATkLeUC5vILdEO2NXNWPczlWnwMFvOdkA +3EeajviunGlcfcF1QlRJrVA9bwPT+fJZFX0uneYVs0c +-> X25519 vm9rPYnQB16VSidi7+nr70lFaH0W/jIGY8zwUObZUV8 +jFgPy/w4j0/p1USKGjQY+coo1OUFXiIjJ5apIZCrZVI +--- Cf2c6WzLYOi8xE/sIn7ZtUqBy5AToASDUNpAxyjrI9M +:,+!ϨϬB4DmH|(9l9LPZ^zed=imz? \ No newline at end of file diff --git a/secrets/infra/zulip-redis-password.age b/secrets/infra/zulip-redis-password.age new file mode 100644 index 0000000..2aff8b6 --- /dev/null +++ b/secrets/infra/zulip-redis-password.age @@ -0,0 +1,11 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q DqDE3ZZlPUWUyyLA185xsOmfGi146SNk+hENMQXaiFY +D6FhZgynbdccPJQiFRJ18EYvCyDLz3cak0YuQa4f5p4 +-> ssh-ed25519 IrZmAg lXgVeADmgjeHeVOOIS5oHqrhkN59ZWDemMOBJo3ubH8 +AQ24P+DnxNoHEguNnLaROIW4/Sq96w/UxzzQwEOyGRc +-> ssh-ed25519 0kWPgQ 8x0pMohdACYueLY6jbNwg7MYVaZcjwBU4axthvDoFx4 +SgUVnd6MK1MccWVYOu9R3PtoMCBBNGKQ7jt5MSA+KkI +-> X25519 UaO5huJPx8d8eMUnGhbI77tZjsFlIPWEffT4fgoO22w +DVz016ibRxJoa4TDmb2m0Qu9Dn8jpjWEBVtdm2TZx0c +--- 5+MHuvC26SjEBFSmRm0kXjiI27QnJGxvPl2w13EkMrw +FoQ]ȟeU//no.XGJ Э|+ž \ No newline at end of file diff --git a/secrets/infra/zulip-secret-key.age b/secrets/infra/zulip-secret-key.age new file mode 100644 index 0000000..d903d66 --- /dev/null +++ b/secrets/infra/zulip-secret-key.age @@ -0,0 +1,11 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q ml+kmLmuRb2nMXJyhKigby2+lPddxM/U7tjhGGQ/JGk +B3UCv/3+4GHeKR964o/m0CoicHwDgWQGEarPW94tb3I +-> ssh-ed25519 IrZmAg AO0ELOuGGj+WanDZFRkHKUEJyZqJYFdhWbqmUfwbpiM +5RZMxVBvW5+TzCBFnn66ry3o5V5cJykweyoYMVBgczY +-> ssh-ed25519 0kWPgQ gqQ/S33Re2OYLz1D9LoSAoqOKxuL4aUes8r6+NyAoXw +NHo2xFsxxJO1ZjnG9r3oxMuvjOUsCyyPvcar2ejZp9w +-> X25519 vUAjBCE197YsckVNM4SYVIPBEESTWnBPCWnUlEwYs1I +L3l85DXFoAVm2ssHfjBeqRpWGlo1UGbmcNkEgoUB9fM +--- X/2O8ufjbTGrt2zCm4gSRqqoxT5v6a+13XjH4dpRsHs +Mkf"(qxF2BdMRYji ܴ<ґb_.!r+<Ussu?gD\V am(Ȉ&.& c/|w(WH4rѠ+j"B  \ No newline at end of file From 7567ab194b67d7c6fa942e6a9d6cbb90b399a184 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:16:51 -0700 Subject: [PATCH 02/14] Fix Tailscale default app and Zulip metadata fetch --- Scripts/authentik-sync-tailscale-oidc.sh | 1 + nixos/modules/burrow-zulip.nix | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh index 9e01b97..45e654e 100755 --- a/Scripts/authentik-sync-tailscale-oidc.sh +++ b/Scripts/authentik-sync-tailscale-oidc.sh @@ -308,6 +308,7 @@ existing_application="$( if [[ -n "$existing_application" ]]; then application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" + api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null else create_application_result="$( api_with_status POST "/api/v3/core/applications/" "$application_payload" diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 0fcad65..6aaae60 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -239,7 +239,7 @@ in : > ${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/)" + 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 re, sys, xml.etree.ElementTree as ET xml = sys.stdin.read() From 8ac1a5c70e0d4b83dc1d08f22e9e9cc67c71a080 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:22:13 -0700 Subject: [PATCH 03/14] Use unified tailnet launcher and fix Zulip RabbitMQ --- nixos/hosts/burrow-forge/default.nix | 3 +-- nixos/modules/burrow-zulip.nix | 12 +----------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 2d943b9..f6d99f9 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -244,8 +244,7 @@ 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"; + defaultExternalApplicationSlug = "ts"; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleAccountMapFile = config.age.secrets.burrowAuthentikGoogleAccountMap.path; diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 6aaae60..e631468 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -41,20 +41,10 @@ let 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"; + RABBITMQ_DEFAULT_PASS_FILE = "/run/secrets/zulip__rabbitmq_password"; }; volumes = [ "rabbitmq:/var/lib/rabbitmq:rw" ]; attach = false; From bd13ff3ee980223bf73302ae5998bc8a1a34cc01 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:25:16 -0700 Subject: [PATCH 04/14] Bind Zulip memcached and RabbitMQ config files --- nixos/modules/burrow-zulip.nix | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index e631468..8366ded 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -25,28 +25,25 @@ let "-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" + echo "zulip@$HOSTNAME:$(cat /run/burrow/memcached-password)" > "$MEMCACHED_SASL_PWDB" + echo "zulip@localhost:$(cat /run/burrow/memcached-password)" >> "$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"; }; + volumes = [ "./secrets/memcached-password:/run/burrow/memcached-password:ro" ]; attach = false; }; rabbitmq = { image = "rabbitmq:4.2"; restart = "unless-stopped"; - secrets = [ "zulip__rabbitmq_password" ]; - environment = { - RABBITMQ_DEFAULT_USER = "zulip"; - RABBITMQ_DEFAULT_PASS_FILE = "/run/secrets/zulip__rabbitmq_password"; - }; - volumes = [ "rabbitmq:/var/lib/rabbitmq:rw" ]; + volumes = [ + "rabbitmq:/var/lib/rabbitmq:rw" + "./rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro" + ]; attach = false; }; redis = { @@ -228,6 +225,12 @@ in 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"} + install -m 0444 ${lib.escapeShellArg cfg.memcachedPasswordFile} ${lib.escapeShellArg "${cfg.dataDir}/secrets/memcached-password"} + cat > ${lib.escapeShellArg "${cfg.dataDir}/rabbitmq.conf"} < Date: Sun, 19 Apr 2026 00:30:08 -0700 Subject: [PATCH 05/14] Declare Zulip compose secrets --- nixos/modules/burrow-zulip.nix | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 8366ded..48a5cbf 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -248,6 +248,10 @@ print((node.text or "").strip()) 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: From b8cad4c028bd1df82233b2ddc1a1cce7bef96eb8 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:52:16 -0700 Subject: [PATCH 06/14] Grant Tailnet access and harden Zulip bootstrap --- Scripts/authentik-sync-linear-saml.sh | 4 +- Scripts/authentik-sync-linear-scim.sh | 7 +- Scripts/authentik-sync-tailscale-oidc.sh | 2 +- Scripts/authentik-sync-zulip-saml.sh | 4 +- nixos/hosts/burrow-forge/default.nix | 1 + nixos/modules/burrow-zulip.nix | 81 +++++++++++++++++++++++- 6 files changed, 90 insertions(+), 9 deletions(-) diff --git a/Scripts/authentik-sync-linear-saml.sh b/Scripts/authentik-sync-linear-saml.sh index 5da64ad..2fd1a90 100755 --- a/Scripts/authentik-sync-linear-saml.sh +++ b/Scripts/authentik-sync-linear-saml.sh @@ -294,8 +294,8 @@ existing_application="$( )" if [[ -n "$existing_application" ]]; then - application_pk="existing" - api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" + api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null else create_application_result="$( api_with_status POST "/api/v3/core/applications/" "$application_payload" diff --git a/Scripts/authentik-sync-linear-scim.sh b/Scripts/authentik-sync-linear-scim.sh index 4ef83e4..5d42cca 100644 --- a/Scripts/authentik-sync-linear-scim.sh +++ b/Scripts/authentik-sync-linear-scim.sh @@ -278,7 +278,12 @@ application_payload="$( policy_engine_mode: .policy_engine_mode }' )" -api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null +application_pk="$(printf '%s\n' "$application" | jq -r '.pk // empty')" +if [[ -z "$application_pk" ]]; then + echo "error: could not resolve Authentik application primary key for ${application_slug}" >&2 + exit 1 +fi +api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null group_pks_json="$(jq -cn --arg owner "$owner_group_pk" --arg admin "$admin_group_pk" --arg guest "$guest_group_pk" '[$owner, $admin, $guest]')" user_pks_json="$( diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh index 45e654e..fde1a01 100755 --- a/Scripts/authentik-sync-tailscale-oidc.sh +++ b/Scripts/authentik-sync-tailscale-oidc.sh @@ -308,7 +308,7 @@ existing_application="$( if [[ -n "$existing_application" ]]; then application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" - api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null + api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null else create_application_result="$( api_with_status POST "/api/v3/core/applications/" "$application_payload" diff --git a/Scripts/authentik-sync-zulip-saml.sh b/Scripts/authentik-sync-zulip-saml.sh index d503ce0..6767991 100644 --- a/Scripts/authentik-sync-zulip-saml.sh +++ b/Scripts/authentik-sync-zulip-saml.sh @@ -344,8 +344,8 @@ existing_application="$( )" if [[ -n "$existing_application" ]]; then - application_pk="existing" - api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" + api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null else create_application_result="$( api_with_status POST "/api/v3/core/applications/" "$application_payload" diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index f6d99f9..2464672 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -251,6 +251,7 @@ in googleLoginMode = "redirect"; userGroupName = contributors.groups.users; adminGroupName = contributors.groups.admins; + tailscaleAccessGroupName = contributors.groups.users; bootstrapUsers = bootstrapUsers; linearAcsUrl = "https://api.linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de/acs"; linearAudience = "https://auth.linear.app/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 48a5cbf..a408c12 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -128,6 +128,18 @@ in 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; @@ -227,6 +239,7 @@ in chmod 0600 ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} install -m 0444 ${lib.escapeShellArg cfg.memcachedPasswordFile} ${lib.escapeShellArg "${cfg.dataDir}/secrets/memcached-password"} cat > ${lib.escapeShellArg "${cfg.dataDir}/rabbitmq.conf"} </dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 90 ]; then + echo "error: RabbitMQ did not become ready for Zulip bootstrap" >&2 + exit 1 + fi + sleep 2 + done + } + + ensure_zulip_volume_layout() { + local zulip_volume_mount + zulip_volume_mount="$(podman volume inspect burrow-zulip_zulip --format '{{.Mountpoint}}')" + install -d -m 0755 "$zulip_volume_mount/logs" + install -d -m 0755 "$zulip_volume_mount/logs/emails" + install -d -m 0700 "$zulip_volume_mount/secrets" + chown 1000:1000 "$zulip_volume_mount/logs" "$zulip_volume_mount/logs/emails" "$zulip_volume_mount/secrets" + + if [ ! -s "$zulip_volume_mount/secrets/bootstrap-owner-password" ]; then + umask 077 + openssl rand -base64 24 > "$zulip_volume_mount/secrets/bootstrap-owner-password" + fi + chown 1000:1000 "$zulip_volume_mount/secrets/bootstrap-owner-password" + chmod 0600 "$zulip_volume_mount/secrets/bootstrap-owner-password" + } + + bootstrap_realm_if_needed() { + local realm_exists + realm_exists="$( + compose run --rm --entrypoint bash zulip -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 + + export ZULIP_REALM_NAME=${lib.escapeShellArg cfg.realmName} + export ZULIP_ADMIN_EMAIL=${lib.escapeShellArg cfg.administratorEmail} + export ZULIP_OWNER_NAME=${lib.escapeShellArg cfg.realmOwnerName} + + compose run --rm --entrypoint bash zulip -lc ' + su zulip -c "/home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated \"$ZULIP_REALM_NAME\" \"$ZULIP_ADMIN_EMAIL\" \"$ZULIP_OWNER_NAME\"" + ' + } + 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 + compose pull + compose up -d database memcached rabbitmq redis + wait_for_rabbitmq + compose run --rm zulip app:init touch .initialized fi - ${pkgs.podman-compose}/bin/podman-compose -p burrow-zulip up -d + compose up -d database memcached rabbitmq redis + wait_for_rabbitmq + ensure_zulip_volume_layout + bootstrap_realm_if_needed + compose up -d zulip ''; }; }; From 824bbd9d671c767acd770a01e0011a6c5f9301c5 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:55:07 -0700 Subject: [PATCH 07/14] Run Zulip bootstrap non-interactively --- nixos/modules/burrow-zulip.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index a408c12..238905b 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -385,7 +385,7 @@ EOF bootstrap_realm_if_needed() { local realm_exists realm_exists="$( - compose run --rm --entrypoint bash zulip -lc \ + compose run --rm -T --entrypoint bash zulip -lc \ "su zulip -c '/home/zulip/deployments/current/manage.py list_realms'" \ | awk '$NF == "https://${cfg.domain}" { print "yes" }' )" @@ -398,7 +398,7 @@ EOF export ZULIP_ADMIN_EMAIL=${lib.escapeShellArg cfg.administratorEmail} export ZULIP_OWNER_NAME=${lib.escapeShellArg cfg.realmOwnerName} - compose run --rm --entrypoint bash zulip -lc ' + compose run --rm -T --entrypoint bash zulip -lc ' su zulip -c "/home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated \"$ZULIP_REALM_NAME\" \"$ZULIP_ADMIN_EMAIL\" \"$ZULIP_OWNER_NAME\"" ' } @@ -407,7 +407,7 @@ EOF compose pull compose up -d database memcached rabbitmq redis wait_for_rabbitmq - compose run --rm zulip app:init + compose run --rm -T zulip app:init touch .initialized fi From b70b62dfef8e4907edeb6825a0b040a6967d5773 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:56:35 -0700 Subject: [PATCH 08/14] Fix Zulip bootstrap user handling --- Scripts/authentik-sync-linear-saml.sh | 4 ++-- Scripts/authentik-sync-linear-scim.sh | 7 +------ nixos/modules/burrow-zulip.nix | 8 ++++---- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/Scripts/authentik-sync-linear-saml.sh b/Scripts/authentik-sync-linear-saml.sh index 2fd1a90..5da64ad 100755 --- a/Scripts/authentik-sync-linear-saml.sh +++ b/Scripts/authentik-sync-linear-saml.sh @@ -294,8 +294,8 @@ existing_application="$( )" if [[ -n "$existing_application" ]]; then - application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" - api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null + application_pk="existing" + api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null else create_application_result="$( api_with_status POST "/api/v3/core/applications/" "$application_payload" diff --git a/Scripts/authentik-sync-linear-scim.sh b/Scripts/authentik-sync-linear-scim.sh index 5d42cca..4ef83e4 100644 --- a/Scripts/authentik-sync-linear-scim.sh +++ b/Scripts/authentik-sync-linear-scim.sh @@ -278,12 +278,7 @@ application_payload="$( policy_engine_mode: .policy_engine_mode }' )" -application_pk="$(printf '%s\n' "$application" | jq -r '.pk // empty')" -if [[ -z "$application_pk" ]]; then - echo "error: could not resolve Authentik application primary key for ${application_slug}" >&2 - exit 1 -fi -api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null +api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null group_pks_json="$(jq -cn --arg owner "$owner_group_pk" --arg admin "$admin_group_pk" --arg guest "$guest_group_pk" '[$owner, $admin, $guest]')" user_pks_json="$( diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 238905b..0db3dfd 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -385,8 +385,8 @@ EOF bootstrap_realm_if_needed() { local realm_exists realm_exists="$( - compose run --rm -T --entrypoint bash zulip -lc \ - "su zulip -c '/home/zulip/deployments/current/manage.py list_realms'" \ + compose run --rm -T -u zulip --entrypoint bash zulip -lc \ + "/home/zulip/deployments/current/manage.py list_realms" \ | awk '$NF == "https://${cfg.domain}" { print "yes" }' )" @@ -398,8 +398,8 @@ EOF export ZULIP_ADMIN_EMAIL=${lib.escapeShellArg cfg.administratorEmail} export ZULIP_OWNER_NAME=${lib.escapeShellArg cfg.realmOwnerName} - compose run --rm -T --entrypoint bash zulip -lc ' - su zulip -c "/home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated \"$ZULIP_REALM_NAME\" \"$ZULIP_ADMIN_EMAIL\" \"$ZULIP_OWNER_NAME\"" + compose run --rm -T -u zulip --entrypoint bash zulip -lc ' + /home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated "$ZULIP_REALM_NAME" "$ZULIP_ADMIN_EMAIL" "$ZULIP_OWNER_NAME" ' } From fa2806e4b36e98df780a1be557bd0d864792cf5d Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:59:34 -0700 Subject: [PATCH 09/14] Bootstrap Zulip from the live app container --- nixos/modules/burrow-zulip.nix | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 0db3dfd..ee6d6c7 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -384,9 +384,19 @@ EOF bootstrap_realm_if_needed() { local realm_exists + local attempts=0 + while ! podman exec burrow-zulip_zulip_1 test -r /etc/zulip/zulip-secrets.conf >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 90 ]; then + echo "error: Zulip did not finish generating production secrets" >&2 + exit 1 + fi + sleep 2 + done + realm_exists="$( - compose run --rm -T -u zulip --entrypoint bash zulip -lc \ - "/home/zulip/deployments/current/manage.py list_realms" \ + 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" }' )" @@ -398,8 +408,8 @@ EOF export ZULIP_ADMIN_EMAIL=${lib.escapeShellArg cfg.administratorEmail} export ZULIP_OWNER_NAME=${lib.escapeShellArg cfg.realmOwnerName} - compose run --rm -T -u zulip --entrypoint bash zulip -lc ' - /home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated "$ZULIP_REALM_NAME" "$ZULIP_ADMIN_EMAIL" "$ZULIP_OWNER_NAME" + podman exec burrow-zulip_zulip_1 bash -lc ' + su zulip -c "/home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated \"$ZULIP_REALM_NAME\" \"$ZULIP_ADMIN_EMAIL\" \"$ZULIP_OWNER_NAME\"" ' } @@ -414,8 +424,8 @@ EOF compose up -d database memcached rabbitmq redis wait_for_rabbitmq ensure_zulip_volume_layout - bootstrap_realm_if_needed compose up -d zulip + bootstrap_realm_if_needed ''; }; }; From 42df7b5618d2d8500d814e7c1839260a38844559 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 01:11:37 -0700 Subject: [PATCH 10/14] Run Zulip on host-managed services --- ...ntik-backed-team-chat-and-workspace-sso.md | 4 + nixos/hosts/burrow-forge/default.nix | 8 - nixos/modules/burrow-zulip.nix | 290 ++++++++++-------- 3 files changed, 170 insertions(+), 132 deletions(-) diff --git a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md index ff6e63d..0ce03a6 100644 --- a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md +++ b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md @@ -49,6 +49,10 @@ across vendor-native Google auth flows when Burrow already operates an IdP. - Add a Burrow-managed Zulip workload on the forge host at `chat.burrow.net`. The deployment should be repo-owned and rebuildable from Nix, even if the runtime uses vendor-supported container images internally. +- Prefer host-managed NixOS services for Zulip's stateful dependencies + (PostgreSQL, Redis, RabbitMQ, memcached, backups) so Burrow owns the + operational surface directly rather than composing a container-side service + mesh. - Zulip should authenticate through Authentik SAML rather than local passwords as the primary path. Initial bootstrap may still keep an operational escape hatch while the deployment is being validated. diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 2464672..be97661 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -170,13 +170,6 @@ in 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"; @@ -275,7 +268,6 @@ in 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; diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index ee6d6c7..b5e72b7 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -5,99 +5,30 @@ let 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 /run/burrow/memcached-password)" > "$MEMCACHED_SASL_PWDB" - echo "zulip@localhost:$(cat /run/burrow/memcached-password)" >> "$MEMCACHED_SASL_PWDB" - exec memcached -S - '' - ]; - environment = { - SASL_CONF_PATH = "/home/memcache/memcached.conf"; - MEMCACHED_SASL_PWDB = "/home/memcache/memcached-sasl-db"; - }; - volumes = [ "./secrets/memcached-password:/run/burrow/memcached-password:ro" ]; - attach = false; - }; - rabbitmq = { - image = "rabbitmq:4.2"; - restart = "unless-stopped"; - volumes = [ - "rabbitmq:/var/lib/rabbitmq:rw" - "./rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro" - ]; - 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"; + network_mode = "host"; 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"; + 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 = [ "zulip:/data:rw" ]; + volumes = [ "${cfg.dataDir}/data:/data:rw" ]; ulimits.nofile = { soft = 1000000; hard = 1048576; }; - depends_on = [ - "database" - "memcached" - "rabbitmq" - "redis" - ]; }; }; - - volumes = { - zulip = { }; - postgresql-14 = { }; - rabbitmq = { }; - redis = { }; - }; }; in { @@ -157,11 +88,6 @@ in 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."; @@ -184,6 +110,49 @@ in 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} @@ -191,18 +160,114 @@ in 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 + ]; + 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"} - install -m 0444 ${lib.escapeShellArg cfg.memcachedPasswordFile} ${lib.escapeShellArg "${cfg.dataDir}/secrets/memcached-password"} - cat > ${lib.escapeShellArg "${cfg.dataDir}/rabbitmq.conf"} </dev/null 2>&1; do - attempts=$((attempts + 1)) - if [ "$attempts" -ge 90 ]; then - echo "error: RabbitMQ did not become ready for Zulip bootstrap" >&2 - exit 1 - fi - sleep 2 - done - } + ensure_zulip_data_layout() { + local zulip_data_dir=${lib.escapeShellArg "${cfg.dataDir}/data"} - ensure_zulip_volume_layout() { - local zulip_volume_mount - zulip_volume_mount="$(podman volume inspect burrow-zulip_zulip --format '{{.Mountpoint}}')" - install -d -m 0755 "$zulip_volume_mount/logs" - install -d -m 0755 "$zulip_volume_mount/logs/emails" - install -d -m 0700 "$zulip_volume_mount/secrets" - chown 1000:1000 "$zulip_volume_mount/logs" "$zulip_volume_mount/logs/emails" "$zulip_volume_mount/secrets" + 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_volume_mount/secrets/bootstrap-owner-password" ]; then + if [ ! -s "$zulip_data_dir/secrets/bootstrap-owner-password" ]; then umask 077 - openssl rand -base64 24 > "$zulip_volume_mount/secrets/bootstrap-owner-password" + openssl rand -base64 24 > "$zulip_data_dir/secrets/bootstrap-owner-password" fi - chown 1000:1000 "$zulip_volume_mount/secrets/bootstrap-owner-password" - chmod 0600 "$zulip_volume_mount/secrets/bootstrap-owner-password" + chown 1000:1000 "$zulip_data_dir/secrets/bootstrap-owner-password" + chmod 0600 "$zulip_data_dir/secrets/bootstrap-owner-password" } bootstrap_realm_if_needed() { @@ -415,15 +461,11 @@ EOF if [ ! -e .initialized ]; then compose pull - compose up -d database memcached rabbitmq redis - wait_for_rabbitmq compose run --rm -T zulip app:init touch .initialized fi - compose up -d database memcached rabbitmq redis - wait_for_rabbitmq - ensure_zulip_volume_layout + ensure_zulip_data_layout compose up -d zulip bootstrap_realm_if_needed ''; From 601bedcc59532f183fa5009b81aef3efa4974c0e Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 01:19:01 -0700 Subject: [PATCH 11/14] Fix Zulip Postgres bootstrap runtime --- nixos/modules/burrow-zulip.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index b5e72b7..3417925 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -179,6 +179,7 @@ in pkgs.bash pkgs.coreutils pkgs.python3 + pkgs.shadow ]; serviceConfig = { Type = "oneshot"; @@ -204,7 +205,7 @@ END ALTER ROLE zulip WITH LOGIN PASSWORD '$db_password_sql'; SQL - su postgres -s ${pkgs.bash}/bin/bash -c "psql -v ON_ERROR_STOP=1 -f '$setup_sql'" + ${pkgs.shadow}/bin/su postgres -s ${pkgs.bash}/bin/bash -c "psql -v ON_ERROR_STOP=1 -f '$setup_sql'" ''; }; From 2ef804fa1051268d25f3a26cdaa39cee73b9a259 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 01:20:55 -0700 Subject: [PATCH 12/14] Use runuser for Zulip Postgres bootstrap --- nixos/modules/burrow-zulip.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 3417925..23ce77b 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -179,7 +179,7 @@ in pkgs.bash pkgs.coreutils pkgs.python3 - pkgs.shadow + pkgs.util-linux ]; serviceConfig = { Type = "oneshot"; @@ -205,7 +205,7 @@ END ALTER ROLE zulip WITH LOGIN PASSWORD '$db_password_sql'; SQL - ${pkgs.shadow}/bin/su postgres -s ${pkgs.bash}/bin/bash -c "psql -v ON_ERROR_STOP=1 -f '$setup_sql'" + ${pkgs.util-linux}/bin/runuser -u postgres -- psql -v ON_ERROR_STOP=1 -f "$setup_sql" ''; }; From 142c2ef77807f9071ae2326e54fc7e7c338b1b52 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 01:22:32 -0700 Subject: [PATCH 13/14] Allow postgres bootstrap to read generated SQL --- nixos/modules/burrow-zulip.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 23ce77b..7d93705 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -204,6 +204,7 @@ 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" ''; From 2af7618f5265471f4048db49eb1353924cf322f6 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 01:31:45 -0700 Subject: [PATCH 14/14] Fix tailscale landing and zulip bootstrap --- Scripts/authentik-sync-tailscale-oidc.sh | 16 +++++++++++++++- nixos/hosts/burrow-forge/default.nix | 2 +- nixos/modules/burrow-zulip.nix | 24 +++++++++++++++++------- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh index fde1a01..58fe7e4 100755 --- a/Scripts/authentik-sync-tailscale-oidc.sh +++ b/Scripts/authentik-sync-tailscale-oidc.sh @@ -137,10 +137,24 @@ lookup_group_pk() { lookup_application_pk() { local slug="$1" + local application_pk lookup_result lookup_status - api GET "/api/v3/core/applications/?page_size=200" \ + application_pk="$( + api GET "/api/v3/core/applications/?page_size=200" \ | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ | head -n1 + )" + + if [[ -n "$application_pk" ]]; then + printf '%s\n' "$application_pk" + return 0 + fi + + lookup_result="$(api_with_status GET "/api/v3/core/applications/${slug}/")" + lookup_status="$(printf '%s\n' "$lookup_result" | sed -n '1p')" + if [[ "$lookup_status" =~ ^20[01]$ ]]; then + printf '%s\n' "$lookup_result" | sed '1d' | jq -r '.pk // empty' + fi } ensure_application_group_binding() { diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index be97661..c4fc92e 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -237,7 +237,7 @@ in forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path; - defaultExternalApplicationSlug = "ts"; + defaultExternalApplicationSlug = "tailscale"; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleAccountMapFile = config.age.secrets.burrowAuthentikGoogleAccountMap.path; diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 7d93705..0096b65 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -404,7 +404,8 @@ EOF 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'"; + 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 @@ -452,13 +453,22 @@ EOF return 0 fi - export ZULIP_REALM_NAME=${lib.escapeShellArg cfg.realmName} - export ZULIP_ADMIN_EMAIL=${lib.escapeShellArg cfg.administratorEmail} - export ZULIP_OWNER_NAME=${lib.escapeShellArg cfg.realmOwnerName} + 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 - podman exec burrow-zulip_zulip_1 bash -lc ' - su zulip -c "/home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated \"$ZULIP_REALM_NAME\" \"$ZULIP_ADMIN_EMAIL\" \"$ZULIP_OWNER_NAME\"" - ' + 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" } if [ ! -e .initialized ]; then