Compare commits

..

No commits in common. "2af7618f5265471f4048db49eb1353924cf322f6" and "7d3e7a6ec56e1739526235a05d2c16857fc4cdfa" have entirely different histories.

13 changed files with 3 additions and 1207 deletions

View file

@ -10,8 +10,6 @@ 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\"
]}"
@ -33,8 +31,6 @@ 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
}
@ -127,111 +123,6 @@ 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="$(
api GET "/api/v3/providers/oauth2/?page_size=200" \
| jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \
@ -322,7 +213,6 @@ 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
else
create_application_result="$(
api_with_status POST "/api/v3/core/applications/" "$application_payload"
@ -349,14 +239,6 @@ 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})."

View file

@ -1,398 +0,0 @@
#!/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,10 +49,6 @@ 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.
@ -72,9 +68,6 @@ 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
@ -118,10 +111,8 @@ 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

View file

@ -214,7 +214,6 @@
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 = {

View file

@ -61,7 +61,6 @@ in
self.nixosModules.burrow-forgejo-nsc
self.nixosModules.burrow-authentik
self.nixosModules.burrow-headscale
self.nixosModules.burrow-zulip
];
system.stateVersion = "24.11";
@ -163,37 +162,9 @@ in
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 = ''
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
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
'';
services.burrow.forge = {
@ -237,14 +208,12 @@ in
forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path;
headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path;
tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path;
defaultExternalApplicationSlug = "tailscale";
googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path;
googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path;
googleAccountMapFile = config.age.secrets.burrowAuthentikGoogleAccountMap.path;
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";
@ -255,7 +224,6 @@ in
linearOwnerGroupName = linearGroups.owners;
linearAdminGroupName = linearGroups.admins;
linearGuestGroupName = linearGroups.guests;
zulipAccessGroupName = contributors.groups.users;
};
services.burrow.headscale = {
@ -263,13 +231,4 @@ in
oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path;
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,7 +12,6 @@ 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;
@ -154,18 +153,6 @@ 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";
@ -199,42 +186,6 @@ 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;
@ -858,12 +809,6 @@ 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}
'';
@ -914,53 +859,6 @@ EOF
'';
};
systemd.services.burrow-authentik-zulip-saml = {
description = "Reconcile the Burrow Authentik Zulip SAML application";
after = [
"burrow-authentik-ready.service"
"network-online.target"
];
wants = [
"burrow-authentik-ready.service"
"network-online.target"
];
wantedBy = [ "multi-user.target" ];
restartTriggers = [
zulipSamlSyncScript
cfg.envFile
];
path = [
pkgs.bash
pkgs.coreutils
pkgs.curl
pkgs.jq
];
serviceConfig = {
Type = "oneshot";
User = "root";
Group = "root";
};
script = ''
set -euo pipefail
set -a
source ${lib.escapeShellArg cfg.envFile}
set +a
export AUTHENTIK_URL=https://${cfg.domain}
export AUTHENTIK_ZULIP_APPLICATION_SLUG=${lib.escapeShellArg cfg.zulipProviderSlug}
export AUTHENTIK_ZULIP_APPLICATION_NAME=Zulip
export AUTHENTIK_ZULIP_PROVIDER_NAME=Zulip
export AUTHENTIK_ZULIP_ACS_URL=${lib.escapeShellArg cfg.zulipAcsUrl}
export AUTHENTIK_ZULIP_AUDIENCE=${lib.escapeShellArg cfg.zulipAudience}
export AUTHENTIK_ZULIP_LAUNCH_URL=${lib.escapeShellArg cfg.zulipLaunchUrl}
${lib.optionalString (cfg.zulipAccessGroupName != null) ''
export AUTHENTIK_ZULIP_ACCESS_GROUP=${lib.escapeShellArg cfg.zulipAccessGroupName}
''}
${pkgs.bash}/bin/bash ${zulipSamlSyncScript}
'';
};
systemd.services.burrow-authentik-linear-saml = lib.mkIf (
cfg.linearAcsUrl != null && cfg.linearAudience != null
) {

View file

@ -1,486 +0,0 @@
{ 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,9 +25,4 @@ in
"secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
"secrets/infra/linear-scim-token.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

@ -1,11 +0,0 @@
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Ô

View file

@ -1,11 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,11 +0,0 @@
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 §