Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions plugins/ldap-user-sync/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# LDAP User Sync

Syncs Dispatcharr user accounts from any standard LDAP directory (Active
Directory, OpenLDAP, 389 Directory Server, Authentik's LDAP outpost,
etc.) based on group membership.

## What it does

- Binds to your LDAP directory (on a schedule and/or on demand) and reads
the members of two group DNs you configure: an **Admin** group and a
**Streamer** group.
- Creates or updates the matching Dispatcharr users, setting `user_level`
to Admin (`10`) or Streamer (`0`). Users in neither group are never
created or touched.
- Generates a random **Xtream Codes (XC) API password** for every newly
created user (the secret your IPTV client apps use — separate from the
Dispatcharr login password) and emails it via plain SMTP. Existing
users keep their XC password stable across future syncs; use the
**Reset XC Password** action to rotate one on demand.
- Disables (`is_active=False`) any previously-synced user who drops out
of both groups on a later sync (toggle-able).
- Optionally enables true **LDAP pass-through login**, so a user's
Dispatcharr login password always matches their live LDAP password
(see below for how this works and its limitations).

## LDAP pass-through login

We cannot read a user's plaintext LDAP password via a service-account
bind — no LDAP server exposes it. So the only way to make a Dispatcharr
login "match LDAP" is live pass-through authentication, not password
mirroring:

- LDAP-managed users get `set_unusable_password()` locally — their local
Dispatcharr password is deterministically disabled.
- When **Enable LDAP pass-through login** is on, a custom Django
authentication backend attempts a live SIMPLE bind against your LDAP
server using the DN captured at the most recent sync and the password
the user typed in. Django's standard `ModelBackend` is tried first, so
non-LDAP accounts (e.g. a local break-glass admin) are unaffected.

**Accepted limitations, by design:**
- If your LDAP server is unreachable, LDAP-managed users cannot log in
until it's back — there's no local fallback for them. To restore local
login for one user in an emergency, clear their
`custom_properties["ldap_synced"]`/`["ldap_dn"]` and call
`set_password()` directly (Django shell / admin).
- Nested/recursive group membership is not expanded — a user must be a
*direct* member of the configured group DN.
- If you fully uninstall this plugin, restart Dispatcharr afterward.
Django's authentication backend list isn't designed to be mutated at
runtime, so an already-running process keeps a reference to the
now-removed backend module until it restarts (it fails safely, closed,
in the meantime — it just won't clean itself up without a restart).

## Vendored dependencies

Dispatcharr plugins run inside the app's existing Python environment
with no dependency-installation step, and neither `ldap3` nor
`python-ldap` ships with Dispatcharr. This plugin vendors, as plain
Python source under `vendor/`:

- [`ldap3`](https://pypi.org/project/ldap3/) 2.9.1 — LGPL-3.0-only.
Full license text: `vendor/licenses/ldap3/`.
- [`pyasn1`](https://pypi.org/project/pyasn1/) 0.6.4 — BSD-2-Clause.
Full license text: `vendor/licenses/pyasn1/LICENSE.rst`.

Both are pure Python with no compiled extensions. They are vendored
unmodified as clearly-separable source trees with their original
license text preserved, satisfying LGPL's combination terms. This
plugin's own code is MIT-licensed; the vendored libraries keep their
original licenses regardless.

CodeQL (which scans this vendored source like any other Python in the
PR) flags a handful of lines in `ldap3`'s optional DIGEST-MD5/NTLM SASL
mechanisms and TLS-version handling — code this plugin never exercises,
since it only ever performs a SIMPLE bind over a connection whose TLS
version is never explicitly set (so it always goes through
`ssl.create_default_context()`'s TLS 1.2+ floor). Those specific lines
carry inline `codeql[<rule-id>]` suppression comments with an
explanation of why each is a protocol-mandated construct rather than a
real weakness, rather than modifying the vendored source itself.

## Settings reference

See the in-app field descriptions (grouped into LDAP Connection, Group →
Role Mapping, User Attribute Mapping, Sync Behavior, Pass-Through Login,
XC Password, and SMTP sections). A couple worth calling out:

- **Group Member Attribute**: `member` works for `groupOfNames`-style
groups (the common case, including AD and Authentik). Set it to
`uniqueMember` for `groupOfUniqueNames`, or turn on **Group lists
usernames, not DNs** and set it to `memberUid` for `posixGroup`-style
directories.
- **Dry Run Mode**: turn this on for your first sync against a real
directory — it logs exactly what would be created/updated/disabled
without writing anything.
3 changes: 3 additions & 0 deletions plugins/ldap-user-sync/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .plugin import Plugin

__all__ = ["Plugin"]
96 changes: 96 additions & 0 deletions plugins/ldap-user-sync/_ldap_user_sync_durable/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Standalone home for `LDAPPassthroughBackend`, kept out of `plugin.py`.

`apps.plugins.loader.PluginManager._unload_package()` evicts every
`sys.modules` entry under the plugin's synthetic package name whenever
the plugin is disabled or force-reloaded. If this backend lived inside
`plugin.py` itself, Django's `AUTHENTICATION_BACKENDS` would hold a
dotted path that stops resolving the moment that happens, breaking the
very next login attempt that reaches it.

This package is deliberately independent: `plugin.py` inserts the
plugin's own folder (not just `vendor/`) onto `sys.path`, so this module
stays importable via normal filesystem lookup even after a
`sys.modules` eviction — a cache eviction just means Python re-imports a
fresh copy from disk instead of failing. It re-does its own `vendor/`
bootstrap below so it never depends on `plugin.py` having already run in
the current call stack.

The name is deliberately unique/underscore-prefixed to avoid colliding
with other plugins' same-named generic helper modules (several ship
their own `utils.py`/`config.py`) now that this plugin's own folder is
on `sys.path`.
"""

import os
import sys

_PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_VENDOR_DIR = os.path.join(_PLUGIN_DIR, "vendor")
if _VENDOR_DIR not in sys.path:
sys.path.insert(0, _VENDOR_DIR)
if _PLUGIN_DIR not in sys.path:
sys.path.insert(0, _PLUGIN_DIR)

from ldap3.core.exceptions import LDAPException # noqa: E402


def _current_settings():
"""Read the plugin's live persisted settings straight from PluginConfig.

Deliberately independent of plugin.py's own settings helper for the
same reload-durability reason described above.
"""
from apps.plugins.models import PluginConfig

plugin_key = os.path.basename(_PLUGIN_DIR).replace(" ", "_").lower()
try:
return PluginConfig.objects.get(key=plugin_key).settings or {}
except PluginConfig.DoesNotExist:
return {}


class LDAPPassthroughBackend:
"""Optional live LDAP bind-as-user authentication, opt-in per settings."""

def authenticate(self, request, username=None, password=None, **kwargs):
if not username or not password:
return None

from apps.accounts.models import User

try:
settings = _current_settings()
if not settings.get("enable_ldap_passthrough_login"):
return None

user = User.objects.get(username=username, is_active=True)
custom_properties = user.custom_properties or {}
if not custom_properties.get("ldap_synced"):
return None
dn = custom_properties.get("ldap_dn")
if not dn:
return None

# Import lazily so a broken vendor/import never breaks the
# ModelBackend path for every other login attempt.
from ldap_client import verify_user_credentials # noqa: PLC0415

if verify_user_credentials(settings, dn, password):
return user
return None
except User.DoesNotExist:
return None
except LDAPException:
return None
except Exception:
# A flaky/down directory or unexpected error must degrade to
# "this backend says no", never to an unhandled 500 on login.
return None

def get_user(self, user_id):
from apps.accounts.models import User

try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None
193 changes: 193 additions & 0 deletions plugins/ldap-user-sync/ldap_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"""Generic LDAP directory access for the LDAP User Sync plugin.

Uses the vendored ``ldap3``/``pyasn1`` (see ``vendor/``) since Dispatcharr
plugins run inside the app's existing Python environment with no
dependency-installation step, and neither library ships with Dispatcharr.

Deliberately vendor-agnostic: no assumptions about Active Directory,
OpenLDAP, or any specific product (including Authentik's LDAP outpost,
which this plugin was developed and tested against, but never hardcodes
to).
"""

import os
import re
import ssl
import sys

_PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
_VENDOR_DIR = os.path.join(_PLUGIN_DIR, "vendor")
if _VENDOR_DIR not in sys.path:
sys.path.insert(0, _VENDOR_DIR)

import ldap3 # noqa: E402
from ldap3 import ALL, BASE, SIMPLE, SUBTREE, Connection, Server, Tls # noqa: E402
from ldap3.core.exceptions import LDAPException # noqa: E402

_USERNAME_RE = re.compile(r"[^\w.@+-]")


class LDAPConnectionError(Exception):
"""Raised for any LDAP connect/bind/search failure, wrapping the underlying cause."""


def sanitize_username(raw):
"""Reduce an LDAP attribute value to Django's allowed username charset."""
value = (raw or "").strip()
return _USERNAME_RE.sub("_", value)


def _build_server(settings):
host = (settings.get("ldap_host") or "").strip()
if not host:
raise LDAPConnectionError("LDAP host is not configured")
try:
port = int(settings.get("ldap_port") or 389)
except (TypeError, ValueError):
port = 389
encryption = settings.get("ldap_encryption") or "starttls"

tls = None
if encryption in ("starttls", "ldaps"):
ca_pem = (settings.get("ldap_ca_cert_pem") or "").strip() or None
# Deliberately leave `version` unset (None): ldap3's Tls.wrap_socket()
# only calls Python's own ssl.create_default_context() - which
# enforces a TLS 1.2+ floor - when version is None. Passing an
# explicit version (e.g. PROTOCOL_TLS_CLIENT) makes it fall through
# to a manual SSLContext(self.version) path with no such floor,
# which is what CodeQL's py/insecure-protocol flags.
tls = Tls(
validate=ssl.CERT_REQUIRED,
ca_certs_data=ca_pem,
)

return Server(
host,
port=port,
use_ssl=(encryption == "ldaps"),
tls=tls,
get_info=ALL,
connect_timeout=10,
), encryption


def connect_service_account(settings):
"""Bind as the configured service account. Raises LDAPConnectionError on any failure."""
server, encryption = _build_server(settings)
bind_dn = (settings.get("ldap_bind_dn") or "").strip()
bind_password = settings.get("ldap_bind_password") or ""

try:
conn = Connection(
server,
user=bind_dn or None,
password=bind_password or None,
authentication=SIMPLE if bind_dn else None,
auto_bind=False,
receive_timeout=10,
)
if encryption == "starttls":
if not conn.open() or not conn.start_tls():
raise LDAPConnectionError(f"STARTTLS negotiation failed: {conn.result}")
if not conn.bind():
raise LDAPConnectionError(f"LDAP bind failed: {conn.result}")
except LDAPException as exc:
raise LDAPConnectionError(f"LDAP connection error: {exc}") from exc

return conn


def verify_user_credentials(settings, dn, password):
"""Attempt a SIMPLE bind as `dn`/`password` on a fresh connection. Never raises."""
if not dn or not password:
return False
try:
server, encryption = _build_server(settings)
conn = Connection(
server,
user=dn,
password=password,
authentication=SIMPLE,
auto_bind=False,
receive_timeout=10,
)
if encryption == "starttls":
if not conn.open() or not conn.start_tls():
return False
return bool(conn.bind())
except Exception:
return False


def resolve_group_members(conn, settings, group_dn):
"""Return the raw list of member values (DNs, or usernames) for a group entry."""
if not group_dn:
return []
member_attr = settings.get("ldap_group_member_attribute") or "member"
ok = conn.search(
search_base=group_dn,
search_filter="(objectClass=*)",
search_scope=BASE,
attributes=[member_attr],
)
if not ok or not conn.entries:
return []
entry = conn.entries[0]
if member_attr not in entry:
return []
values = entry[member_attr].values
return [str(v) for v in values]


def fetch_user_by_dn(conn, dn, settings):
"""Look up a single user entry by DN. Returns a normalized dict or None if stale."""
attrs = [
settings.get("ldap_username_attribute") or "uid",
settings.get("ldap_email_attribute") or "mail",
settings.get("ldap_first_name_attribute") or "givenName",
settings.get("ldap_last_name_attribute") or "sn",
]
ok = conn.search(search_base=dn, search_filter="(objectClass=*)", search_scope=BASE, attributes=attrs)
if not ok or not conn.entries:
return None
return _normalize_entry(conn.entries[0], dn, settings)


def fetch_user_by_username(conn, settings, username):
"""Look up a single user entry by username attribute under the base DN."""
base_dn = (settings.get("ldap_base_dn") or "").strip()
username_attr = settings.get("ldap_username_attribute") or "uid"
attrs = [
username_attr,
settings.get("ldap_email_attribute") or "mail",
settings.get("ldap_first_name_attribute") or "givenName",
settings.get("ldap_last_name_attribute") or "sn",
]
escaped = ldap3.utils.conv.escape_filter_chars(username)
ok = conn.search(
search_base=base_dn,
search_filter=f"({username_attr}={escaped})",
search_scope=SUBTREE,
attributes=attrs,
)
if not ok or not conn.entries:
return None
entry = conn.entries[0]
return _normalize_entry(entry, str(entry.entry_dn), settings)


def _first(entry, attr):
if attr not in entry:
return ""
values = entry[attr].values
return str(values[0]) if values else ""


def _normalize_entry(entry, dn, settings):
return {
"dn": dn,
"username": sanitize_username(_first(entry, settings.get("ldap_username_attribute") or "uid")),
"email": _first(entry, settings.get("ldap_email_attribute") or "mail"),
"first_name": _first(entry, settings.get("ldap_first_name_attribute") or "givenName"),
"last_name": _first(entry, settings.get("ldap_last_name_attribute") or "sn"),
}
Binary file added plugins/ldap-user-sync/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading