Compare commits

...

14 commits

Author SHA1 Message Date
Conrad Kramer
2af7618f52 Fix tailscale landing and zulip bootstrap
Some checks failed
Build Rust / Cargo Test (push) Successful in 3m55s
Build Site / Next.js Build (push) Failing after 2s
Lint Governance / BEP Metadata (push) Successful in 0s
2026-04-19 01:31:45 -07:00
Conrad Kramer
142c2ef778 Allow postgres bootstrap to read generated SQL 2026-04-19 01:22:32 -07:00
Conrad Kramer
2ef804fa10 Use runuser for Zulip Postgres bootstrap 2026-04-19 01:20:55 -07:00
Conrad Kramer
601bedcc59 Fix Zulip Postgres bootstrap runtime 2026-04-19 01:19:01 -07:00
Conrad Kramer
42df7b5618 Run Zulip on host-managed services 2026-04-19 01:11:37 -07:00
Conrad Kramer
fa2806e4b3 Bootstrap Zulip from the live app container 2026-04-19 00:59:34 -07:00
Conrad Kramer
b70b62dfef Fix Zulip bootstrap user handling 2026-04-19 00:56:35 -07:00
Conrad Kramer
824bbd9d67 Run Zulip bootstrap non-interactively 2026-04-19 00:55:07 -07:00
Conrad Kramer
b8cad4c028 Grant Tailnet access and harden Zulip bootstrap 2026-04-19 00:52:16 -07:00
Conrad Kramer
801e0fb419 Declare Zulip compose secrets 2026-04-19 00:30:08 -07:00
Conrad Kramer
bd13ff3ee9 Bind Zulip memcached and RabbitMQ config files 2026-04-19 00:25:16 -07:00
Conrad Kramer
8ac1a5c70e Use unified tailnet launcher and fix Zulip RabbitMQ 2026-04-19 00:22:13 -07:00
Conrad Kramer
7567ab194b Fix Tailscale default app and Zulip metadata fetch 2026-04-19 00:16:51 -07:00
Conrad Kramer
44f437c33c Expose Tailscale and add Zulip SAML deployment 2026-04-19 00:13:10 -07:00
13 changed files with 1207 additions and 3 deletions

View file

@ -10,6 +10,8 @@ template_slug="${AUTHENTIK_TAILSCALE_TEMPLATE_SLUG:-ts}"
client_id="${AUTHENTIK_TAILSCALE_CLIENT_ID:-tailscale.burrow.net}" client_id="${AUTHENTIK_TAILSCALE_CLIENT_ID:-tailscale.burrow.net}"
client_secret="${AUTHENTIK_TAILSCALE_CLIENT_SECRET:-}" client_secret="${AUTHENTIK_TAILSCALE_CLIENT_SECRET:-}"
launch_url="${AUTHENTIK_TAILSCALE_LAUNCH_URL:-https://login.tailscale.com/start/oidc}" 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:-[ redirect_uris_json="${AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON:-[
\"https://login.tailscale.com/a/oauth_response\" \"https://login.tailscale.com/a/oauth_response\"
]}" ]}"
@ -31,6 +33,8 @@ Optional environment:
AUTHENTIK_TAILSCALE_CLIENT_ID AUTHENTIK_TAILSCALE_CLIENT_ID
AUTHENTIK_TAILSCALE_LAUNCH_URL AUTHENTIK_TAILSCALE_LAUNCH_URL
AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON
AUTHENTIK_TAILSCALE_ACCESS_GROUP
AUTHENTIK_DEFAULT_EXTERNAL_APPLICATION_SLUG
EOF EOF
} }
@ -123,6 +127,111 @@ wait_for_authentik() {
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"
local application_pk lookup_result lookup_status
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() {
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="$( template_provider="$(
api GET "/api/v3/providers/oauth2/?page_size=200" \ api GET "/api/v3/providers/oauth2/?page_size=200" \
| jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \ | jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \
@ -213,6 +322,7 @@ existing_application="$(
if [[ -n "$existing_application" ]]; then if [[ -n "$existing_application" ]]; then
application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')"
api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null
else else
create_application_result="$( create_application_result="$(
api_with_status POST "/api/v3/core/applications/" "$application_payload" api_with_status POST "/api/v3/core/applications/" "$application_payload"
@ -239,6 +349,14 @@ if [[ -z "${application_pk:-}" ]]; then
exit 1 exit 1
fi 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 for _ in $(seq 1 30); do
if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then 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})." echo "Synced Authentik Tailscale OIDC application ${application_slug} (${application_name})."

View file

@ -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="$(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"
)"
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})."

View file

@ -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`. - 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 The deployment should be repo-owned and rebuildable from Nix, even if the
runtime uses vendor-supported container images internally. 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 - Zulip should authenticate through Authentik SAML rather than local passwords
as the primary path. Initial bootstrap may still keep an operational escape as the primary path. Initial bootstrap may still keep an operational escape
hatch while the deployment is being validated. hatch while the deployment is being validated.
@ -68,6 +72,9 @@ across vendor-native Google auth flows when Burrow already operates an IdP.
options instead of hand-edited UI state. options instead of hand-edited UI state.
- Prefer service-specific reconciliation over ad hoc manual setup so rebuilds - Prefer service-specific reconciliation over ad hoc manual setup so rebuilds
and host replacement converge automatically. 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. - 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 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 configuration should map that intent onto the Linear push group without a
@ -113,6 +120,8 @@ across vendor-native Google auth flows when Burrow already operates an IdP.
- Authentik exposes working metadata for Zulip and Linear - 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 - 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: - Record concrete evidence for:
- host deployment generation - host deployment generation
- Authentik reconciliation success - Authentik reconciliation success

View file

@ -214,6 +214,7 @@
nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default; nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default;
nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix; nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix;
nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.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 { nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem {
system = "x86_64-linux"; system = "x86_64-linux";
specialArgs = { specialArgs = {

View file

@ -61,6 +61,7 @@ in
self.nixosModules.burrow-forgejo-nsc self.nixosModules.burrow-forgejo-nsc
self.nixosModules.burrow-authentik self.nixosModules.burrow-authentik
self.nixosModules.burrow-headscale self.nixosModules.burrow-headscale
self.nixosModules.burrow-zulip
]; ];
system.stateVersion = "24.11"; system.stateVersion = "24.11";
@ -162,9 +163,37 @@ in
mode = "0400"; mode = "0400";
}; };
age.secrets.burrowZulipPostgresPassword = {
file = ../../../secrets/infra/zulip-postgres-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 = '' networking.extraHosts = ''
127.0.0.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 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 = { services.burrow.forge = {
@ -208,12 +237,14 @@ in
forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path;
headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path;
tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path; tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path;
defaultExternalApplicationSlug = "tailscale";
googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path;
googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path;
googleAccountMapFile = config.age.secrets.burrowAuthentikGoogleAccountMap.path; googleAccountMapFile = config.age.secrets.burrowAuthentikGoogleAccountMap.path;
googleLoginMode = "redirect"; googleLoginMode = "redirect";
userGroupName = contributors.groups.users; userGroupName = contributors.groups.users;
adminGroupName = contributors.groups.admins; adminGroupName = contributors.groups.admins;
tailscaleAccessGroupName = contributors.groups.users;
bootstrapUsers = bootstrapUsers; bootstrapUsers = bootstrapUsers;
linearAcsUrl = "https://api.linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de/acs"; linearAcsUrl = "https://api.linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de/acs";
linearAudience = "https://auth.linear.app/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; linearAudience = "https://auth.linear.app/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de";
@ -224,6 +255,7 @@ in
linearOwnerGroupName = linearGroups.owners; linearOwnerGroupName = linearGroups.owners;
linearAdminGroupName = linearGroups.admins; linearAdminGroupName = linearGroups.admins;
linearGuestGroupName = linearGroups.guests; linearGuestGroupName = linearGroups.guests;
zulipAccessGroupName = contributors.groups.users;
}; };
services.burrow.headscale = { services.burrow.headscale = {
@ -231,4 +263,13 @@ in
oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path;
bootstrapUsers = headscaleBootstrapUsers; bootstrapUsers = headscaleBootstrapUsers;
}; };
services.burrow.zulip = {
enable = true;
administratorEmail = identities.contact.canonicalEmail;
postgresPasswordFile = config.age.secrets.burrowZulipPostgresPassword.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; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh;
tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh;
onePasswordOidcSyncScript = ../../Scripts/authentik-sync-1password-oidc.sh; onePasswordOidcSyncScript = ../../Scripts/authentik-sync-1password-oidc.sh;
zulipSamlSyncScript = ../../Scripts/authentik-sync-zulip-saml.sh;
linearSamlSyncScript = ../../Scripts/authentik-sync-linear-saml.sh; linearSamlSyncScript = ../../Scripts/authentik-sync-linear-saml.sh;
linearScimSyncScript = ../../Scripts/authentik-sync-linear-scim.sh; linearScimSyncScript = ../../Scripts/authentik-sync-linear-scim.sh;
googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh;
@ -153,6 +154,18 @@ in
description = "Host-local file containing the Authentik Tailscale OIDC client secret."; 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 { onePasswordDomain = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "burrow-team.1password.com"; default = "burrow-team.1password.com";
@ -186,6 +199,42 @@ in
description = "Authentik application slug for Linear SAML."; 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 { linearAcsUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = null; default = null;
@ -809,6 +858,12 @@ EOF
export AUTHENTIK_TAILSCALE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.tailscaleClientSecretFile})" 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_LAUNCH_URL=https://login.tailscale.com/start/oidc
export AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON='["https://login.tailscale.com/a/oauth_response"]' 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} ${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 ( systemd.services.burrow-authentik-linear-saml = lib.mkIf (
cfg.linearAcsUrl != null && cfg.linearAudience != null cfg.linearAcsUrl != null && cfg.linearAudience != null
) { ) {

View file

@ -0,0 +1,486 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.burrow.zulip;
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: "EmailAuthBackend,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"
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",
},
}
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"
}
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="$(
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"
}
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
'';
};
};
}

View file

@ -25,4 +25,9 @@ in
"secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
"secrets/infra/linear-scim-token.age".publicKeys = burrowForgeRecipients; "secrets/infra/linear-scim-token.age".publicKeys = burrowForgeRecipients;
"secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
"secrets/infra/zulip-postgres-password.age".publicKeys = burrowForgeRecipients;
"secrets/infra/zulip-memcached-password.age".publicKeys = burrowForgeRecipients;
"secrets/infra/zulip-rabbitmq-password.age".publicKeys = burrowForgeRecipients;
"secrets/infra/zulip-redis-password.age".publicKeys = burrowForgeRecipients;
"secrets/infra/zulip-secret-key.age".publicKeys = burrowForgeRecipients;
} }

View file

@ -0,0 +1,11 @@
age-encryption.org/v1
-> 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
¨¤ãö9a¿ª5ÌIµÛÙƒçŽèV[fÀÁàç,Dàb \æv&§L½­Z7õ!åû?4=JxFíÁeVÔ

Binary file not shown.

View file

@ -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
˜:Š,+!°¬›ÏÛöϨϬB4DÿŸmHè÷®|Ä(çŸø9ñl9†LßPZ^ïzed=im¡óëz¢æ?øŸ

View file

@ -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
Óí¯FžoQ˜˜ƒ]ÈŸ­‹õ‘ˆþš‰ûëeU³/óíÑ/ó®ÂnÀoø.XþGù£J •Э¹|‡<>õ+ïž­

View file

@ -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
÷¼âM¬kÜf"¹¬ž(qëxöÅèF2Bd…ÖMRîYji ¯õˆÖ°Ü´Å<¿òøÒ‘¾™Ñí¯b_.!r+¸<ÀÞUsž²sëô<C3AB>Êæ<C38A>uí?g«ÉDçã\Öðú<C3B0>V aÊÓÄÓ¨mÇã(ȈÞ&.ñ& Àc/|½w˜²ƒ(Wð¡ÁýïªHޝœ4rÑ ¾¸á°æ+ j"üñÙB §