authentik.crypto.tasks

Crypto tasks

  1"""Crypto tasks"""
  2
  3from glob import glob
  4from pathlib import Path
  5from sys import platform
  6
  7from cryptography.hazmat.backends import default_backend
  8from cryptography.hazmat.primitives.serialization import load_pem_private_key
  9from cryptography.x509.base import load_pem_x509_certificate
 10from django.conf import settings
 11from django.utils.translation import gettext_lazy as _
 12from dramatiq.actor import actor
 13from dramatiq.middleware import Middleware
 14from structlog.stdlib import get_logger
 15from watchdog.events import (
 16    FileCreatedEvent,
 17    FileModifiedEvent,
 18    FileSystemEvent,
 19    FileSystemEventHandler,
 20)
 21from watchdog.observers import Observer
 22
 23from authentik.crypto.models import CertificateKeyPair
 24from authentik.lib.config import CONFIG
 25from authentik.tasks.middleware import CurrentTask
 26from authentik.tasks.schedules.models import Schedule
 27from authentik.tenants.models import Tenant
 28
 29LOGGER = get_logger()
 30
 31MANAGED_DISCOVERED = "goauthentik.io/crypto/discovered/%s"
 32
 33
 34def ensure_private_key_valid(body: str):
 35    """Attempt loading of a PEM Private key without password"""
 36    load_pem_private_key(
 37        str.encode("\n".join([x.strip() for x in body.split("\n")])),
 38        password=None,
 39        backend=default_backend(),
 40    )
 41    return body
 42
 43
 44def ensure_certificate_valid(body: str):
 45    """Attempt loading of a PEM-encoded certificate"""
 46    load_pem_x509_certificate(body.encode("utf-8"), default_backend())
 47    return body
 48
 49
 50class CertificateWatcherMiddleware(Middleware):
 51    """Middleware to start certificate file watcher"""
 52
 53    def start_certificate_watcher(self):
 54        """Start certificate file watcher"""
 55        observer = Observer()
 56        kwargs = {}
 57        if platform.startswith("linux"):
 58            kwargs["event_filter"] = (FileCreatedEvent, FileModifiedEvent)
 59        observer.schedule(
 60            CertificateEventHandler(),
 61            CONFIG.get("cert_discovery_dir"),
 62            recursive=True,
 63            **kwargs,
 64        )
 65        observer.start()
 66
 67    def after_worker_boot(self, broker, worker):
 68        if not settings.TEST:
 69            self.start_certificate_watcher()
 70
 71
 72class CertificateEventHandler(FileSystemEventHandler):
 73    """Event handler for certificate file events"""
 74
 75    # We only ever get creation and modification events.
 76    # See the creation of the Observer instance above for the event filtering.
 77
 78    # Even though we filter to only get file events, we might still get
 79    # directory events as some implementations such as inotify do not support
 80    # filtering on file/directory.
 81
 82    def dispatch(self, event: FileSystemEvent) -> None:
 83        """Call specific event handler method. Ignores directory changes."""
 84        if event.is_directory:
 85            return None
 86        return super().dispatch(event)
 87
 88    def on_created(self, event: FileSystemEvent):
 89        """Process certificate file creation"""
 90        LOGGER.debug(
 91            "Certificate file created, triggering discovery",
 92            file=event.src_path,
 93        )
 94        for tenant in Tenant.objects.filter(ready=True):
 95            with tenant:
 96                Schedule.dispatch_by_actor(certificate_discovery)
 97
 98    def on_modified(self, event: FileSystemEvent):
 99        """Process certificate file modification"""
100        LOGGER.debug(
101            "Certificate file modified, triggering discovery",
102            file=event.src_path,
103        )
104        for tenant in Tenant.objects.filter(ready=True):
105            with tenant:
106                Schedule.dispatch_by_actor(certificate_discovery)
107
108
109@actor(description=_("Discover, import and update certificates from the filesystem."))
110def certificate_discovery():
111    self = CurrentTask.get_task()
112    certs = {}
113    private_keys = {}
114    discovered = 0
115    for file in glob(CONFIG.get("cert_discovery_dir") + "/**", recursive=True):
116        path = Path(file)
117        if not path.exists() or path.is_dir():
118            continue
119        # For certbot setups, we want to ignore archive.
120        if "archive" in file:
121            continue
122        # Handle additionalOutputFormats from cert-manager gracefully
123        if path.name in ["ca.crt", "tls-combined.pem", "key.der"]:
124            continue
125        # Support certbot & kubernetes.io/tls directory structure
126        if path.name in ["fullchain.pem", "privkey.pem", "tls.crt", "tls.key"]:
127            cert_name = path.parent.name
128        else:
129            cert_name = path.name.replace(path.suffix, "")
130        try:
131            with open(path, encoding="utf-8") as _file:
132                body = _file.read()
133                if "PRIVATE KEY" in body:
134                    private_keys[cert_name] = ensure_private_key_valid(body)
135                else:
136                    certs[cert_name] = ensure_certificate_valid(body)
137            discovered += 1
138        except (OSError, ValueError) as exc:
139            LOGGER.warning("Failed to open file or invalid format", exc=exc, file=path)
140    for name, cert_data in certs.items():
141        # First, try to find by filename-based managed field
142        cert = CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % name).first()
143
144        # If not found by filename and we have a private key, check for existing key match
145        if not cert and name in private_keys:
146            existing_with_key = (
147                CertificateKeyPair.objects.filter(
148                    managed__startswith="goauthentik.io/crypto/discovered/",
149                    key_data=private_keys[name],
150                )
151                .exclude(key_data="")
152                .first()
153            )
154            if existing_with_key:
155                cert = existing_with_key
156                # Update name and managed field to reflect the new filename
157                if cert.name != name:
158                    cert.name = name
159                    cert.managed = MANAGED_DISCOVERED % name
160                    cert.save()
161
162        # Create new certificate if not found
163        if not cert:
164            cert = CertificateKeyPair(
165                name=name,
166                managed=MANAGED_DISCOVERED % name,
167            )
168
169        # Update certificate data if changed
170        dirty = False
171        if cert.certificate_data != cert_data:
172            cert.certificate_data = cert_data
173            dirty = True
174        if name in private_keys:
175            if cert.key_data != private_keys[name]:
176                cert.key_data = private_keys[name]
177                dirty = True
178        if dirty:
179            cert.save()
180    self.info(f"Successfully imported {discovered} files.")
LOGGER = <BoundLoggerLazyProxy(logger=None, wrapper_class=None, processors=None, context_class=None, initial_values={}, logger_factory_args=())>
MANAGED_DISCOVERED = 'goauthentik.io/crypto/discovered/%s'
def ensure_private_key_valid(body: str):
35def ensure_private_key_valid(body: str):
36    """Attempt loading of a PEM Private key without password"""
37    load_pem_private_key(
38        str.encode("\n".join([x.strip() for x in body.split("\n")])),
39        password=None,
40        backend=default_backend(),
41    )
42    return body

Attempt loading of a PEM Private key without password

def ensure_certificate_valid(body: str):
45def ensure_certificate_valid(body: str):
46    """Attempt loading of a PEM-encoded certificate"""
47    load_pem_x509_certificate(body.encode("utf-8"), default_backend())
48    return body

Attempt loading of a PEM-encoded certificate

class CertificateWatcherMiddleware(dramatiq.middleware.middleware.Middleware):
51class CertificateWatcherMiddleware(Middleware):
52    """Middleware to start certificate file watcher"""
53
54    def start_certificate_watcher(self):
55        """Start certificate file watcher"""
56        observer = Observer()
57        kwargs = {}
58        if platform.startswith("linux"):
59            kwargs["event_filter"] = (FileCreatedEvent, FileModifiedEvent)
60        observer.schedule(
61            CertificateEventHandler(),
62            CONFIG.get("cert_discovery_dir"),
63            recursive=True,
64            **kwargs,
65        )
66        observer.start()
67
68    def after_worker_boot(self, broker, worker):
69        if not settings.TEST:
70            self.start_certificate_watcher()

Middleware to start certificate file watcher

def start_certificate_watcher(self):
54    def start_certificate_watcher(self):
55        """Start certificate file watcher"""
56        observer = Observer()
57        kwargs = {}
58        if platform.startswith("linux"):
59            kwargs["event_filter"] = (FileCreatedEvent, FileModifiedEvent)
60        observer.schedule(
61            CertificateEventHandler(),
62            CONFIG.get("cert_discovery_dir"),
63            recursive=True,
64            **kwargs,
65        )
66        observer.start()

Start certificate file watcher

def after_worker_boot(self, broker, worker):
68    def after_worker_boot(self, broker, worker):
69        if not settings.TEST:
70            self.start_certificate_watcher()

Called after the worker process has started up.

class CertificateEventHandler(watchdog.events.FileSystemEventHandler):
 73class CertificateEventHandler(FileSystemEventHandler):
 74    """Event handler for certificate file events"""
 75
 76    # We only ever get creation and modification events.
 77    # See the creation of the Observer instance above for the event filtering.
 78
 79    # Even though we filter to only get file events, we might still get
 80    # directory events as some implementations such as inotify do not support
 81    # filtering on file/directory.
 82
 83    def dispatch(self, event: FileSystemEvent) -> None:
 84        """Call specific event handler method. Ignores directory changes."""
 85        if event.is_directory:
 86            return None
 87        return super().dispatch(event)
 88
 89    def on_created(self, event: FileSystemEvent):
 90        """Process certificate file creation"""
 91        LOGGER.debug(
 92            "Certificate file created, triggering discovery",
 93            file=event.src_path,
 94        )
 95        for tenant in Tenant.objects.filter(ready=True):
 96            with tenant:
 97                Schedule.dispatch_by_actor(certificate_discovery)
 98
 99    def on_modified(self, event: FileSystemEvent):
100        """Process certificate file modification"""
101        LOGGER.debug(
102            "Certificate file modified, triggering discovery",
103            file=event.src_path,
104        )
105        for tenant in Tenant.objects.filter(ready=True):
106            with tenant:
107                Schedule.dispatch_by_actor(certificate_discovery)

Event handler for certificate file events

def dispatch(self, event: watchdog.events.FileSystemEvent) -> None:
83    def dispatch(self, event: FileSystemEvent) -> None:
84        """Call specific event handler method. Ignores directory changes."""
85        if event.is_directory:
86            return None
87        return super().dispatch(event)

Call specific event handler method. Ignores directory changes.

def on_created(self, event: watchdog.events.FileSystemEvent):
89    def on_created(self, event: FileSystemEvent):
90        """Process certificate file creation"""
91        LOGGER.debug(
92            "Certificate file created, triggering discovery",
93            file=event.src_path,
94        )
95        for tenant in Tenant.objects.filter(ready=True):
96            with tenant:
97                Schedule.dispatch_by_actor(certificate_discovery)

Process certificate file creation

def on_modified(self, event: watchdog.events.FileSystemEvent):
 99    def on_modified(self, event: FileSystemEvent):
100        """Process certificate file modification"""
101        LOGGER.debug(
102            "Certificate file modified, triggering discovery",
103            file=event.src_path,
104        )
105        for tenant in Tenant.objects.filter(ready=True):
106            with tenant:
107                Schedule.dispatch_by_actor(certificate_discovery)

Process certificate file modification