Compare commits

...

3 Commits

Author SHA1 Message Date
Jens Langhammer
fbce9611d2 fix dep, make post request
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-07-17 21:06:05 +02:00
Jens Langhammer
e6643a69cd add in app support bundle
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-07-17 18:18:39 +02:00
Jens Langhammer
0fdeaee559 add support command
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-07-17 18:17:49 +02:00
12 changed files with 284 additions and 27 deletions

View File

@@ -42,7 +42,11 @@ class Exporter:
if model in self.excluded_models: if model in self.excluded_models:
continue continue
for obj in self.get_model_instances(model): for obj in self.get_model_instances(model):
yield BlueprintEntry.from_model(obj) yield BlueprintEntry.from_model(self.alter_model(obj))
def alter_model(self, model: Model):
"""Hook to modify the model before exporting"""
return model
def get_model_instances(self, model: type[Model]) -> QuerySet: def get_model_instances(self, model: type[Model]) -> QuerySet:
"""Return a queryset for `model`. Can be used to filter some """Return a queryset for `model`. Can be used to filter some

View File

@@ -2,6 +2,7 @@
from datetime import timedelta from datetime import timedelta
from django.http import HttpResponse
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@@ -10,17 +11,21 @@ from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, IntegerField from rest_framework.fields import CharField, IntegerField
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import BaseRenderer
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.validators import UniqueValidator from rest_framework.validators import UniqueValidator
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.models import User, UserTypes from authentik.core.models import User, UserTypes
from authentik.enterprise.bundle import generate_support_bundle
from authentik.enterprise.license import LicenseKey, LicenseSummarySerializer from authentik.enterprise.license import LicenseKey, LicenseSummarySerializer
from authentik.enterprise.models import License from authentik.enterprise.models import License
from authentik.rbac.decorators import permission_required from authentik.rbac.decorators import permission_required
from authentik.rbac.permissions import HasPermission
from authentik.tenants.utils import get_unique_identifier from authentik.tenants.utils import get_unique_identifier
@@ -147,3 +152,24 @@ class LicenseViewSet(UsedByMixin, ModelViewSet):
) )
response.is_valid(raise_exception=True) response.is_valid(raise_exception=True)
return Response(response.data) return Response(response.data)
class BinaryRenderer(BaseRenderer):
media_type = "application/gzip"
format = "bin"
class SupportBundleView(APIView):
"""Generate a support bundle."""
permission_classes = [HasPermission("authentik_rbac.view_system_info")]
pagination_class = None
filter_backends = []
renderer_classes = [BinaryRenderer]
@extend_schema(responses=bytes, request=None)
def post(self, request: Request) -> Response:
"""Generate a support bundle."""
response = HttpResponse(generate_support_bundle(), content_type=BinaryRenderer.media_type)
response["Content-Disposition"] = 'attachment; filename="authentik_support.tgz"'
return response

View File

@@ -0,0 +1,53 @@
import re
from io import BytesIO
from tarfile import TarInfo, open
from django.db.models import Model
from django.db.models.fields import CharField, SlugField, TextField
from django.db.models.fields.json import JSONField
from authentik.blueprints.v1.exporter import Exporter
from authentik.core.models import User
from lifecycle.support import encrypt, generate
SENSITIVE_VALUE_PLACEHOLDER = "<REDACTED>"
class SupportExporter(Exporter):
"""Blueprint exporter which censors sensitive model attributes"""
sensitive_fields = re.compile(
# Partially taken from Django's SafeExceptionReporterFilter
"API|AUTH|TOKEN|KEY|SECRET|PASS|SIGNATURE|CREDENTIALS",
re.I,
)
def __init__(self):
super().__init__()
self.excluded_models.append(User)
def alter_model(self, model: Model):
for field in model._meta.fields:
if not self.sensitive_fields.search(field.name):
continue
if isinstance(field, TextField | CharField | SlugField):
setattr(model, field.name, SENSITIVE_VALUE_PLACEHOLDER)
elif isinstance(field, JSONField):
setattr(model, field.name, {})
return model
def generate_support_bundle():
fh = BytesIO()
exporter = SupportExporter()
files = {
"authentik/support.jwe": encrypt(generate()),
"authentik/blueprint.yaml": exporter.export_to_string(),
}
with open(fileobj=fh, mode="w:gz") as tar:
for path, file in files.items():
info = TarInfo(path)
info.size = len(file)
tar.addfile(info, BytesIO(file.encode()))
final_data = fh.getvalue()
return final_data

View File

@@ -1,7 +1,12 @@
"""API URLs""" """API URLs"""
from authentik.enterprise.api import LicenseViewSet from django.urls import path
from authentik.enterprise.api import LicenseViewSet, SupportBundleView
api_urlpatterns = [ api_urlpatterns = [
("enterprise/license", LicenseViewSet), ("enterprise/license", LicenseViewSet),
path(
"enterprise/support_bundle/", SupportBundleView.as_view(), name="enterprise_support_bundle"
),
] ]

View File

@@ -100,6 +100,9 @@ elif [[ "$1" == "healthcheck" ]]; then
elif [[ "$1" == "dump_config" ]]; then elif [[ "$1" == "dump_config" ]]; then
shift shift
exec python -m authentik.lib.config $@ exec python -m authentik.lib.config $@
elif [[ "$1" == "support" ]]; then
wait_for_db
exec python -m lifecycle.support
elif [[ "$1" == "debug" ]]; then elif [[ "$1" == "debug" ]]; then
exec sleep infinity exec sleep infinity
else else

View File

@@ -7,10 +7,11 @@ from os import environ, system
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from psycopg import Connection, Cursor, connect from psycopg import Connection, Cursor
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG, django_db_config from authentik.lib.config import CONFIG, django_db_config
from lifecycle.wait_for_db import get_postgres
LOGGER = get_logger() LOGGER = get_logger()
ADV_LOCK_UID = 1000 ADV_LOCK_UID = 1000
@@ -71,17 +72,7 @@ def release_lock(cursor: Cursor):
def run_migrations(): def run_migrations():
conn = connect( conn = get_postgres()
dbname=CONFIG.get("postgresql.name"),
user=CONFIG.get("postgresql.user"),
password=CONFIG.get("postgresql.password"),
host=CONFIG.get("postgresql.host"),
port=CONFIG.get_int("postgresql.port"),
sslmode=CONFIG.get("postgresql.sslmode"),
sslrootcert=CONFIG.get("postgresql.sslrootcert"),
sslcert=CONFIG.get("postgresql.sslcert"),
sslkey=CONFIG.get("postgresql.sslkey"),
)
curr = conn.cursor() curr = conn.cursor()
try: try:
wait_for_lock(curr) wait_for_lock(curr)

111
lifecycle/support.py Normal file
View File

@@ -0,0 +1,111 @@
import platform
from hashlib import sha512
from pprint import pprint
from ssl import OPENSSL_VERSION
from sys import version as python_version
from cryptography.exceptions import InternalError
from cryptography.hazmat.backends.openssl.backend import backend
from jwcrypto.common import json_encode
from jwcrypto.jwe import JWE
from jwcrypto.jwk import JWK
from jwt import encode
from psutil import cpu_count, virtual_memory
from redis import Redis
from authentik import get_full_version
from authentik.lib.config import CONFIG
from authentik.lib.utils.reflection import get_env
from authentik.root.install_id import get_install_id_raw
from lifecycle.wait_for_db import get_postgres, get_redis
try:
backend._enable_fips()
except InternalError:
pass
def get_version_history():
with get_postgres() as postgres:
cur = postgres.cursor()
cur.execute("""SELECT "timestamp", "version", "build" FROM authentik_version_history;""")
for x, y, z in cur.fetchall():
yield (x.timestamp(), y, z)
def get_postgres_version():
with get_postgres() as postgres:
cur = postgres.cursor()
cur.execute("""SELECT version();""")
return cur.fetchone()[0]
def get_redis_version():
redis: Redis = get_redis()
version = redis.info()
redis.close()
return f"{version["redis_version"]} {version["redis_mode"]} {version["os"]}"
def get_limited_config():
return {
"postgresql": {
"host": CONFIG.get("postgresql.host"),
},
"redis": {
"host": CONFIG.get("redis.host"),
},
"debug": CONFIG.get_bool("debug"),
"log_level": CONFIG.get("log_level"),
"error_reporting": {
"enabled": CONFIG.get_bool("error_reporting.enabled"),
},
}
def generate():
payload = {
"version": {
"history": list(get_version_history()),
"current": get_full_version(),
"postgres": get_postgres_version(),
"redis": get_redis_version(),
"ssl": OPENSSL_VERSION,
"python": python_version,
},
"env": get_env(),
"install_id_hash": sha512(get_install_id_raw().encode("ascii")).hexdigest()[:16],
"system": {
"cpu": {"count": cpu_count()},
"fips": backend._fips_enabled,
"memory_bytes": virtual_memory().total,
"architecture": platform.machine(),
"platform": platform.platform(),
"uname": " ".join(platform.uname()),
},
"config": get_limited_config(),
}
return payload
def encrypt(raw):
with open("authentik/enterprise/public.pem", "rb") as _key:
key = JWK.from_pem(_key.read())
jwe = JWE(
encode(raw, "foo"),
json_encode(
{
"alg": "ECDH-ES+A256KW",
"enc": "A256CBC-HS512",
"typ": "JWE",
}
),
)
jwe.add_recipient(key)
return jwe.serialize(compact=True)
if __name__ == "__main__":
data = generate()
snippet = encrypt(data)
pprint(data)

View File

@@ -13,23 +13,32 @@ from authentik.lib.config import CONFIG, redis_url
CHECK_THRESHOLD = 30 CHECK_THRESHOLD = 30
def get_postgres():
return connect(
dbname=CONFIG.get("postgresql.name"),
user=CONFIG.get("postgresql.user"),
password=CONFIG.get("postgresql.password"),
host=CONFIG.get("postgresql.host"),
port=CONFIG.get_int("postgresql.port"),
sslmode=CONFIG.get("postgresql.sslmode"),
sslrootcert=CONFIG.get("postgresql.sslrootcert"),
sslcert=CONFIG.get("postgresql.sslcert"),
sslkey=CONFIG.get("postgresql.sslkey"),
)
def get_redis():
url = CONFIG.get("cache.url") or redis_url(CONFIG.get("redis.db"))
return Redis.from_url(url)
def check_postgres(): def check_postgres():
attempt = 0 attempt = 0
while True: while True:
if attempt >= CHECK_THRESHOLD: if attempt >= CHECK_THRESHOLD:
sysexit(1) sysexit(1)
try: try:
conn = connect( conn = get_postgres()
dbname=CONFIG.refresh("postgresql.name"),
user=CONFIG.refresh("postgresql.user"),
password=CONFIG.refresh("postgresql.password"),
host=CONFIG.refresh("postgresql.host"),
port=CONFIG.get_int("postgresql.port"),
sslmode=CONFIG.get("postgresql.sslmode"),
sslrootcert=CONFIG.get("postgresql.sslrootcert"),
sslcert=CONFIG.get("postgresql.sslcert"),
sslkey=CONFIG.get("postgresql.sslkey"),
)
conn.cursor() conn.cursor()
break break
except OperationalError as exc: except OperationalError as exc:
@@ -41,13 +50,12 @@ def check_postgres():
def check_redis(): def check_redis():
url = CONFIG.get("cache.url") or redis_url(CONFIG.get("redis.db"))
attempt = 0 attempt = 0
while True: while True:
if attempt >= CHECK_THRESHOLD: if attempt >= CHECK_THRESHOLD:
sysexit(1) sysexit(1)
try: try:
redis = Redis.from_url(url) redis = get_redis()
redis.ping() redis.ping()
break break
except RedisError as exc: except RedisError as exc:

View File

@@ -48,6 +48,7 @@ dependencies = [
"opencontainers==0.0.15", "opencontainers==0.0.15",
"packaging==25.0", "packaging==25.0",
"paramiko==3.5.1", "paramiko==3.5.1",
"psutil==7.0.0",
"psycopg[c,pool]==3.2.9", "psycopg[c,pool]==3.2.9",
"pydantic==2.11.7", "pydantic==2.11.7",
"pydantic-scim==0.0.8", "pydantic-scim==0.0.8",

View File

@@ -7006,6 +7006,34 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
/enterprise/support_bundle/:
post:
operationId: enterprise_support_bundle_create
description: Generate a support bundle.
tags:
- enterprise
security:
- authentik: []
responses:
'200':
content:
application/gzip:
schema:
type: string
format: binary
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/events/events/: /events/events/:
get: get:
operationId: events_events_list operationId: events_events_list

17
uv.lock generated
View File

@@ -211,6 +211,7 @@ dependencies = [
{ name = "opencontainers" }, { name = "opencontainers" },
{ name = "packaging" }, { name = "packaging" },
{ name = "paramiko" }, { name = "paramiko" },
{ name = "psutil" },
{ name = "psycopg", extra = ["c", "pool"] }, { name = "psycopg", extra = ["c", "pool"] },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-scim" }, { name = "pydantic-scim" },
@@ -310,6 +311,7 @@ requires-dist = [
{ name = "opencontainers", git = "https://github.com/vsoch/oci-python?rev=ceb4fcc090851717a3069d78e85ceb1e86c2740c" }, { name = "opencontainers", git = "https://github.com/vsoch/oci-python?rev=ceb4fcc090851717a3069d78e85ceb1e86c2740c" },
{ name = "packaging", specifier = "==25.0" }, { name = "packaging", specifier = "==25.0" },
{ name = "paramiko", specifier = "==3.5.1" }, { name = "paramiko", specifier = "==3.5.1" },
{ name = "psutil", specifier = "==7.0.0" },
{ name = "psycopg", extras = ["c", "pool"], specifier = "==3.2.9" }, { name = "psycopg", extras = ["c", "pool"], specifier = "==3.2.9" },
{ name = "pydantic", specifier = "==2.11.7" }, { name = "pydantic", specifier = "==2.11.7" },
{ name = "pydantic-scim", specifier = "==0.0.8" }, { name = "pydantic-scim", specifier = "==0.0.8" },
@@ -2411,6 +2413,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/af/ab3c51ab7507a7325e98ffe691d9495ee3d3aa5f589afad65ec920d39821/protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e", size = 168724, upload-time = "2025-05-28T19:25:53.926Z" }, { url = "https://files.pythonhosted.org/packages/f7/af/ab3c51ab7507a7325e98ffe691d9495ee3d3aa5f589afad65ec920d39821/protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e", size = 168724, upload-time = "2025-05-28T19:25:53.926Z" },
] ]
[[package]]
name = "psutil"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" },
{ url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" },
{ url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" },
{ url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" },
{ url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" },
]
[[package]] [[package]]
name = "psycopg" name = "psycopg"
version = "3.2.9" version = "3.2.9"

View File

@@ -261,6 +261,16 @@ export class EnterpriseLicenseListPage extends TablePage<License> {
>${msg("Go to Customer Portal")}</a >${msg("Go to Customer Portal")}</a
> >
</div> </div>
<div class="pf-c-card__body">
<ak-action-button
class="pf-m-secondary pf-m-block"
.apiRequest=${() => {
return new EnterpriseApi(DEFAULT_CONFIG).enterpriseSupportBundleCreate();
}}
>
${msg("Create support bundle")}
</ak-action-button>
</div>
<div class="pf-c-card__body"> <div class="pf-c-card__body">
<a target="_blank" href="https://docs.goauthentik.io/docs/enterprise/get-started" <a target="_blank" href="https://docs.goauthentik.io/docs/enterprise/get-started"
>${msg("Learn more")}</a >${msg("Learn more")}</a