ldapgate 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ pip-wheel-metadata/
20
+ share/python-wheels/
21
+ *.egg-info/
22
+ .installed.cfg
23
+ *.egg
24
+ MANIFEST
25
+ .venv
26
+ .venv/
27
+
28
+ # pytest
29
+ .pytest_cache/
30
+ .coverage
31
+ htmlcov/
32
+
33
+ # IDE
34
+ .vscode/
35
+ .idea/
36
+ *.swp
37
+ *.swo
38
+ *~
39
+
40
+ # OS
41
+ .DS_Store
42
+ Thumbs.db
43
+
44
+ # Local testing
45
+ ldapgate.yaml
46
+ memory/
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: ldapgate
3
+ Version: 0.1.1
4
+ Summary: Lightweight LDAP/AD authentication proxy and FastAPI middleware
5
+ Author-email: Anudeep D <anudeep@example.com>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: click>=8.1.0
9
+ Requires-Dist: fastapi>=0.111.0
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: itsdangerous>=2.2.0
12
+ Requires-Dist: jinja2>=3.1.0
13
+ Requires-Dist: ldap3>=2.9.1
14
+ Requires-Dist: pydantic-settings>=2.0
15
+ Requires-Dist: pydantic>=2.7.0
16
+ Requires-Dist: python-multipart>=0.0.9
17
+ Requires-Dist: pyyaml>=6.0
18
+ Requires-Dist: uvicorn[standard]>=0.29.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: httpx[http2]>=0.27; extra == 'dev'
21
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
22
+ Requires-Dist: pytest>=7.0; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ <p align="center">
26
+ <img src="https://raw.githubusercontent.com/anudeepd/ldapgate/main/assets/logo.svg" alt="LDAPGate" width="120"/>
27
+ </p>
28
+
29
+ <h1 align="center">LDAPGate</h1>
30
+
31
+ <p align="center">Lightweight LDAP/AD authentication gateway for Python web apps. Install it, configure it, done.</p>
32
+
33
+ ## Features
34
+
35
+ - **Two deployment modes** — standalone reverse proxy or drop-in FastAPI middleware
36
+ - **Pure Python LDAP** — no OS-level libs required, uses `ldap3`
37
+ - **Signed cookie sessions** — stateless, no server-side session storage
38
+ - **OpenLDAP and Active Directory** — `uid=` and `sAMAccountName=` out of the box
39
+ - **Optional group gating** — restrict access to members of a specific LDAP group
40
+ - **Header injection** — injects `X-Forwarded-User` for apps that support it
41
+ - **Bundled login form** — responsive, dark/light mode, works air-gapped
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install ldapgate
47
+ ```
48
+
49
+ ## Config file
50
+
51
+ Both modes share the same `ldapgate.yaml`:
52
+
53
+ ```yaml
54
+ ldap:
55
+ url: ldaps://dc.example.com:636
56
+ bind_dn: CN=svc,CN=Users,DC=example,DC=com
57
+ bind_password: secret
58
+ base_dn: DC=example,DC=com
59
+ user_filter: "(sAMAccountName={username})" # AD; OpenLDAP: (uid={username})
60
+ group_dn: CN=app-users,CN=Users,DC=example,DC=com # optional
61
+
62
+ proxy:
63
+ listen_host: 0.0.0.0
64
+ listen_port: 9000
65
+ backend_url: http://localhost:8080
66
+ secret_key: change-me-to-something-random
67
+ session_ttl: 3600
68
+ user_header: X-Forwarded-User
69
+ login_path: /_auth/login
70
+ app_name: MyApp
71
+ ```
72
+
73
+ All settings can also be provided via environment variables with `__` separators (e.g. `LDAP__URL`, `PROXY__SECRET_KEY`).
74
+
75
+ ## Mode 1 — Standalone Reverse Proxy
76
+
77
+ Run ldapgate as a standalone process in front of any app. Only authenticated requests are forwarded to the backend.
78
+
79
+ ```
80
+ Browser → ldapgate :9000 → backend app :8080
81
+ ```
82
+
83
+ ```bash
84
+ ldapgate serve --config ldapgate.yaml
85
+ ```
86
+
87
+ **Example: copyparty**
88
+
89
+ Start copyparty with IDP header auth and point its logout at ldapgate:
90
+
91
+ ```bash
92
+ copyparty -p 8080 --idp-h-usr X-Forwarded-User --idp-logout /_auth/logout -v ~/Documents:/:rw
93
+ ```
94
+
95
+ copyparty trusts the `X-Forwarded-User` header injected by ldapgate, and its logout button redirects to `/_auth/logout` which clears the ldapgate session before sending the user back to the login page.
96
+
97
+ ## Mode 2 — FastAPI Middleware
98
+
99
+ Drop ldapgate auth directly into an existing FastAPI app — no separate process needed.
100
+
101
+ ```python
102
+ from fastapi import FastAPI
103
+ from ldapgate.config import load_config
104
+ from ldapgate.middleware import add_ldap_auth
105
+
106
+ app = FastAPI()
107
+ config = load_config("ldapgate.yaml")
108
+ add_ldap_auth(app, config)
109
+
110
+ @app.get("/api/data")
111
+ async def data(request):
112
+ return {"user": request.state.user} # authenticated username
113
+ ```
114
+
115
+ **Example: lagun** — see [lagun](https://github.com/anudeepd/lagun) and [torrus](https://github.com/anudeepd/torrus) for real-world integrations.
116
+
117
+ ## CLI Options
118
+
119
+ ```
120
+ ldapgate serve --config PATH Path to ldapgate.yaml [default: ldapgate.yaml]
121
+ --host TEXT Override listen host
122
+ --port INTEGER Override listen port
123
+ --backend TEXT Override backend URL
124
+ --reload Enable auto-reload (dev)
125
+ ```
126
+
127
+ ## Development
128
+
129
+ Requires [uv](https://github.com/astral-sh/uv).
130
+
131
+ ```bash
132
+ git clone https://github.com/anudeepd/ldapgate
133
+ cd ldapgate
134
+ uv sync
135
+ pytest tests/
136
+ ```
137
+
138
+ ## License
139
+
140
+ MIT
@@ -0,0 +1,116 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/anudeepd/ldapgate/main/assets/logo.svg" alt="LDAPGate" width="120"/>
3
+ </p>
4
+
5
+ <h1 align="center">LDAPGate</h1>
6
+
7
+ <p align="center">Lightweight LDAP/AD authentication gateway for Python web apps. Install it, configure it, done.</p>
8
+
9
+ ## Features
10
+
11
+ - **Two deployment modes** — standalone reverse proxy or drop-in FastAPI middleware
12
+ - **Pure Python LDAP** — no OS-level libs required, uses `ldap3`
13
+ - **Signed cookie sessions** — stateless, no server-side session storage
14
+ - **OpenLDAP and Active Directory** — `uid=` and `sAMAccountName=` out of the box
15
+ - **Optional group gating** — restrict access to members of a specific LDAP group
16
+ - **Header injection** — injects `X-Forwarded-User` for apps that support it
17
+ - **Bundled login form** — responsive, dark/light mode, works air-gapped
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install ldapgate
23
+ ```
24
+
25
+ ## Config file
26
+
27
+ Both modes share the same `ldapgate.yaml`:
28
+
29
+ ```yaml
30
+ ldap:
31
+ url: ldaps://dc.example.com:636
32
+ bind_dn: CN=svc,CN=Users,DC=example,DC=com
33
+ bind_password: secret
34
+ base_dn: DC=example,DC=com
35
+ user_filter: "(sAMAccountName={username})" # AD; OpenLDAP: (uid={username})
36
+ group_dn: CN=app-users,CN=Users,DC=example,DC=com # optional
37
+
38
+ proxy:
39
+ listen_host: 0.0.0.0
40
+ listen_port: 9000
41
+ backend_url: http://localhost:8080
42
+ secret_key: change-me-to-something-random
43
+ session_ttl: 3600
44
+ user_header: X-Forwarded-User
45
+ login_path: /_auth/login
46
+ app_name: MyApp
47
+ ```
48
+
49
+ All settings can also be provided via environment variables with `__` separators (e.g. `LDAP__URL`, `PROXY__SECRET_KEY`).
50
+
51
+ ## Mode 1 — Standalone Reverse Proxy
52
+
53
+ Run ldapgate as a standalone process in front of any app. Only authenticated requests are forwarded to the backend.
54
+
55
+ ```
56
+ Browser → ldapgate :9000 → backend app :8080
57
+ ```
58
+
59
+ ```bash
60
+ ldapgate serve --config ldapgate.yaml
61
+ ```
62
+
63
+ **Example: copyparty**
64
+
65
+ Start copyparty with IDP header auth and point its logout at ldapgate:
66
+
67
+ ```bash
68
+ copyparty -p 8080 --idp-h-usr X-Forwarded-User --idp-logout /_auth/logout -v ~/Documents:/:rw
69
+ ```
70
+
71
+ copyparty trusts the `X-Forwarded-User` header injected by ldapgate, and its logout button redirects to `/_auth/logout` which clears the ldapgate session before sending the user back to the login page.
72
+
73
+ ## Mode 2 — FastAPI Middleware
74
+
75
+ Drop ldapgate auth directly into an existing FastAPI app — no separate process needed.
76
+
77
+ ```python
78
+ from fastapi import FastAPI
79
+ from ldapgate.config import load_config
80
+ from ldapgate.middleware import add_ldap_auth
81
+
82
+ app = FastAPI()
83
+ config = load_config("ldapgate.yaml")
84
+ add_ldap_auth(app, config)
85
+
86
+ @app.get("/api/data")
87
+ async def data(request):
88
+ return {"user": request.state.user} # authenticated username
89
+ ```
90
+
91
+ **Example: lagun** — see [lagun](https://github.com/anudeepd/lagun) and [torrus](https://github.com/anudeepd/torrus) for real-world integrations.
92
+
93
+ ## CLI Options
94
+
95
+ ```
96
+ ldapgate serve --config PATH Path to ldapgate.yaml [default: ldapgate.yaml]
97
+ --host TEXT Override listen host
98
+ --port INTEGER Override listen port
99
+ --backend TEXT Override backend URL
100
+ --reload Enable auto-reload (dev)
101
+ ```
102
+
103
+ ## Development
104
+
105
+ Requires [uv](https://github.com/astral-sh/uv).
106
+
107
+ ```bash
108
+ git clone https://github.com/anudeepd/ldapgate
109
+ cd ldapgate
110
+ uv sync
111
+ pytest tests/
112
+ ```
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,28 @@
1
+ <svg viewBox="0 0 420 120" xmlns="http://www.w3.org/2000/svg">
2
+ <!-- Icon background -->
3
+ <rect x="4" y="4" width="112" height="112" rx="22" fill="#eff6ff"/>
4
+
5
+ <!-- Gate posts -->
6
+ <line x1="36" y1="94" x2="36" y2="52" stroke="#1d4ed8" stroke-width="5" stroke-linecap="round"/>
7
+ <line x1="84" y1="94" x2="84" y2="52" stroke="#1d4ed8" stroke-width="5" stroke-linecap="round"/>
8
+
9
+ <!-- Gate arch -->
10
+ <path d="M 36 52 Q 60 26 84 52" fill="none" stroke="#1d4ed8" stroke-width="5" stroke-linecap="round"/>
11
+
12
+ <!-- Gate crossbar -->
13
+ <line x1="36" y1="72" x2="84" y2="72" stroke="#1d4ed8" stroke-width="3.5" stroke-linecap="round"/>
14
+
15
+ <!-- Padlock shackle -->
16
+ <path d="M 51 72 L 51 64 Q 51 57 60 57 Q 69 57 69 64 L 69 72"
17
+ fill="none" stroke="#3b82f6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
18
+
19
+ <!-- Padlock body -->
20
+ <rect x="47" y="70" width="26" height="20" rx="4" fill="#eff6ff" stroke="#3b82f6" stroke-width="3"/>
21
+
22
+ <!-- Keyhole -->
23
+ <circle cx="60" cy="78" r="2.5" fill="#3b82f6"/>
24
+ <line x1="60" y1="80" x2="60" y2="84" stroke="#3b82f6" stroke-width="2" stroke-linecap="round"/>
25
+
26
+ <!-- Wordmark -->
27
+ <text x="136" y="74" font-family="'Inter', 'Helvetica Neue', sans-serif" font-size="52" font-weight="600" letter-spacing="-1" fill="#1e3a8a">LDAPGate</text>
28
+ </svg>
@@ -0,0 +1,28 @@
1
+ <svg viewBox="0 0 420 120" xmlns="http://www.w3.org/2000/svg">
2
+ <!-- Icon background -->
3
+ <rect x="4" y="4" width="112" height="112" rx="22" fill="#eff6ff"/>
4
+
5
+ <!-- Gate posts -->
6
+ <line x1="36" y1="94" x2="36" y2="52" stroke="#1d4ed8" stroke-width="5" stroke-linecap="round"/>
7
+ <line x1="84" y1="94" x2="84" y2="52" stroke="#1d4ed8" stroke-width="5" stroke-linecap="round"/>
8
+
9
+ <!-- Gate arch -->
10
+ <path d="M 36 52 Q 60 26 84 52" fill="none" stroke="#1d4ed8" stroke-width="5" stroke-linecap="round"/>
11
+
12
+ <!-- Gate crossbar -->
13
+ <line x1="36" y1="72" x2="84" y2="72" stroke="#1d4ed8" stroke-width="3.5" stroke-linecap="round"/>
14
+
15
+ <!-- Padlock shackle -->
16
+ <path d="M 51 72 L 51 64 Q 51 57 60 57 Q 69 57 69 64 L 69 72"
17
+ fill="none" stroke="#3b82f6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
18
+
19
+ <!-- Padlock body -->
20
+ <rect x="47" y="70" width="26" height="20" rx="4" fill="#eff6ff" stroke="#3b82f6" stroke-width="3"/>
21
+
22
+ <!-- Keyhole -->
23
+ <circle cx="60" cy="78" r="2.5" fill="#3b82f6"/>
24
+ <line x1="60" y1="80" x2="60" y2="84" stroke="#3b82f6" stroke-width="2" stroke-linecap="round"/>
25
+
26
+ <!-- Wordmark -->
27
+ <text x="136" y="74" font-family="'Inter', 'Helvetica Neue', sans-serif" font-size="52" font-weight="600" letter-spacing="-1" fill="#dbeafe">LDAPGate</text>
28
+ </svg>
@@ -0,0 +1,25 @@
1
+ <svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
2
+ <!-- Rounded square background -->
3
+ <rect x="6" y="6" width="188" height="188" rx="36" fill="#eff6ff"/>
4
+
5
+ <!-- Gate posts -->
6
+ <line x1="58" y1="158" x2="58" y2="86" stroke="#1d4ed8" stroke-width="9" stroke-linecap="round"/>
7
+ <line x1="142" y1="158" x2="142" y2="86" stroke="#1d4ed8" stroke-width="9" stroke-linecap="round"/>
8
+
9
+ <!-- Gate arch -->
10
+ <path d="M 58 86 Q 100 40 142 86" fill="none" stroke="#1d4ed8" stroke-width="9" stroke-linecap="round"/>
11
+
12
+ <!-- Gate crossbar -->
13
+ <line x1="58" y1="120" x2="142" y2="120" stroke="#1d4ed8" stroke-width="6" stroke-linecap="round"/>
14
+
15
+ <!-- Padlock shackle -->
16
+ <path d="M 88 120 L 88 107 Q 88 94 100 94 Q 112 94 112 107 L 112 120"
17
+ fill="none" stroke="#3b82f6" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
18
+
19
+ <!-- Padlock body (fill covers crossbar behind it) -->
20
+ <rect x="83" y="117" width="34" height="27" rx="5" fill="#eff6ff" stroke="#3b82f6" stroke-width="5"/>
21
+
22
+ <!-- Keyhole -->
23
+ <circle cx="100" cy="128" r="3.5" fill="#3b82f6"/>
24
+ <line x1="100" y1="131" x2="100" y2="137" stroke="#3b82f6" stroke-width="3.5" stroke-linecap="round"/>
25
+ </svg>
@@ -0,0 +1,15 @@
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN pip install uv
6
+
7
+ COPY pyproject.toml uv.lock README.md ./
8
+ COPY ldapgate/ ./ldapgate/
9
+ COPY assets/ ./assets/
10
+
11
+ RUN uv pip install --system .
12
+
13
+ EXPOSE 9000
14
+
15
+ CMD ["ldapgate", "serve", "--config", "/app/ldapgate.yaml"]
@@ -0,0 +1,16 @@
1
+ dn: ou=users,dc=example,dc=org
2
+ objectClass: organizationalUnit
3
+ ou: users
4
+
5
+ dn: uid=alice,ou=users,dc=example,dc=org
6
+ objectClass: inetOrgPerson
7
+ objectClass: posixAccount
8
+ objectClass: shadowAccount
9
+ uid: alice
10
+ cn: Alice
11
+ sn: Test
12
+ mail: alice@example.org
13
+ userPassword: alice123
14
+ uidNumber: 1000
15
+ gidNumber: 1000
16
+ homeDirectory: /home/alice
@@ -0,0 +1,31 @@
1
+ services:
2
+
3
+ openldap:
4
+ image: osixia/openldap:1.5.0
5
+ environment:
6
+ LDAP_ORGANISATION: "Example"
7
+ LDAP_DOMAIN: "example.org"
8
+ LDAP_ADMIN_PASSWORD: "admin"
9
+ volumes:
10
+ - ./alice.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/alice.ldif:ro
11
+ command: --copy-service
12
+ ports:
13
+ - "389:389"
14
+
15
+ httpbin:
16
+ image: kennethreitz/httpbin
17
+ ports:
18
+ - "8080:80"
19
+
20
+ ldapgate:
21
+ build:
22
+ context: ..
23
+ dockerfile: docker/Dockerfile
24
+ volumes:
25
+ - ./ldapgate.local.yaml:/app/ldapgate.yaml:ro
26
+ ports:
27
+ - "9000:9000"
28
+ depends_on:
29
+ - openldap
30
+ - httpbin
31
+ command: ["ldapgate", "serve", "--config", "/app/ldapgate.yaml"]
@@ -0,0 +1,19 @@
1
+ ldap:
2
+ url: ldap://openldap:389
3
+ bind_dn: cn=admin,dc=example,dc=org
4
+ bind_password: "admin"
5
+ base_dn: dc=example,dc=org
6
+ user_filter: "(uid={username})"
7
+ timeout: 10
8
+ follow_referrals: false
9
+
10
+ proxy:
11
+ listen_host: 0.0.0.0
12
+ listen_port: 9000
13
+ backend_url: http://httpbin:80
14
+ secret_key: local-dev-secret-not-for-production
15
+ session_ttl: 3600
16
+ user_header: X-Forwarded-User
17
+ login_path: /_auth/login
18
+ app_name: ldapgate-local
19
+ secure_cookies: false
@@ -0,0 +1,19 @@
1
+ """ldapgate - LDAP/AD authentication proxy and FastAPI middleware"""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = [
6
+ "LDAPAuthenticator",
7
+ "LDAPConfig",
8
+ "LDAPAuthMiddleware",
9
+ "SessionManager",
10
+ "create_proxy_app",
11
+ "create_login_router",
12
+ "add_ldap_auth",
13
+ ]
14
+
15
+ from ldapgate.config import LDAPConfig
16
+ from ldapgate.ldap import LDAPAuthenticator
17
+ from ldapgate.middleware import LDAPAuthMiddleware, add_ldap_auth
18
+ from ldapgate.proxy import create_proxy_app, create_login_router
19
+ from ldapgate.sessions import SessionManager
@@ -0,0 +1,121 @@
1
+ """CLI for ldapgate reverse proxy."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import click
7
+ import uvicorn
8
+
9
+ from ldapgate.config import load_config
10
+ from ldapgate.proxy import create_proxy_app
11
+
12
+ # Env var used to pass config path to the module-level app factory for --reload mode
13
+ _CONFIG_ENV_VAR = "LDAPGATE_CONFIG_PATH"
14
+
15
+
16
+ @click.group()
17
+ def cli():
18
+ """ldapgate - LDAP/AD authentication proxy."""
19
+ pass
20
+
21
+
22
+ @cli.command()
23
+ @click.option(
24
+ "--config",
25
+ type=click.Path(path_type=Path),
26
+ default=None,
27
+ help="Path to ldapgate.yaml config file (reads env vars if omitted)",
28
+ )
29
+ @click.option(
30
+ "--host",
31
+ default=None,
32
+ help="Override listen host (default: 0.0.0.0)",
33
+ )
34
+ @click.option(
35
+ "--port",
36
+ type=int,
37
+ default=None,
38
+ help="Override listen port (default: 9000)",
39
+ )
40
+ @click.option(
41
+ "--backend",
42
+ default=None,
43
+ help="Override backend URL",
44
+ )
45
+ @click.option(
46
+ "--reload",
47
+ is_flag=True,
48
+ help="Enable auto-reload on code changes",
49
+ )
50
+ def serve(config: Path, host: str, port: int, backend: str, reload: bool):
51
+ """Start ldapgate reverse proxy server.
52
+
53
+ Example:
54
+ ldapgate serve --config ldapgate.yaml
55
+ ldapgate serve --backend http://localhost:3923
56
+ """
57
+ try:
58
+ cfg = load_config(config)
59
+
60
+ if host:
61
+ cfg.proxy.listen_host = host
62
+ if port:
63
+ cfg.proxy.listen_port = port
64
+ if backend:
65
+ cfg.proxy.backend_url = backend
66
+
67
+ click.echo(
68
+ f"Starting ldapgate proxy on {cfg.proxy.listen_host}:{cfg.proxy.listen_port}"
69
+ )
70
+ click.echo(f"Backend: {cfg.proxy.backend_url}")
71
+ click.echo(f"Login path: {cfg.proxy.login_path}")
72
+
73
+ if reload:
74
+ # Reload mode requires an import string; pass config + overrides via env vars
75
+ # so the factory in this module can reconstruct the app identically.
76
+ if config:
77
+ os.environ[_CONFIG_ENV_VAR] = str(config)
78
+ if backend:
79
+ os.environ["LDAPGATE_BACKEND_URL"] = backend
80
+ uvicorn.run(
81
+ "ldapgate.cli:_reload_app_factory",
82
+ factory=True,
83
+ host=cfg.proxy.listen_host,
84
+ port=cfg.proxy.listen_port,
85
+ reload=True,
86
+ log_level="info",
87
+ )
88
+ else:
89
+ app = create_proxy_app(cfg)
90
+ uvicorn.run(
91
+ app,
92
+ host=cfg.proxy.listen_host,
93
+ port=cfg.proxy.listen_port,
94
+ log_level="info",
95
+ )
96
+
97
+ except FileNotFoundError as e:
98
+ click.echo(f"Error: {e}", err=True)
99
+ raise SystemExit(1)
100
+ except Exception as e:
101
+ click.echo(f"Error: {e}", err=True)
102
+ raise SystemExit(1)
103
+
104
+
105
+ def _reload_app_factory():
106
+ """App factory used by uvicorn --reload (needs importable callable)."""
107
+ config_path = os.environ.get(_CONFIG_ENV_VAR)
108
+ cfg = load_config(config_path)
109
+ backend = os.environ.get("LDAPGATE_BACKEND_URL")
110
+ if backend:
111
+ cfg.proxy.backend_url = backend
112
+ return create_proxy_app(cfg)
113
+
114
+
115
+ def main():
116
+ """Entry point for ldapgate CLI."""
117
+ cli()
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()