Added initial container definition for uffd-ldapd

Signed-off-by: Nis Wechselberg <enbewe@enbewe.de>
This commit is contained in:
Nis Wechselberg 2025-06-09 16:50:47 +02:00
parent f36cc07b65
commit 27d78f4719
Signed by: enbewe
GPG key ID: 7B25171F921B9E57
4 changed files with 402 additions and 0 deletions

21
Containerfile Normal file
View file

@ -0,0 +1,21 @@
FROM docker.io/library/debian:bookworm-20250520-slim
RUN apt-get -qq update && \
apt-get -qq dist-upgrade && \
apt-get -qq install ca-certificates
# Add key and config for cccv repository
COPY cccv-archive-key.asc /etc/apt/trusted.gpg.d/
COPY cccv-archive.list /etc/apt/sources.list.d/
# Install uffd-ldapd from (new) package sources
RUN apt-get -qq update && \
apt-get -qq install uffd-ldapd
# Copy the patched version of uffd-ldapd (for now) to allow SIGTERM handling
COPY uffd-ldapd /usr/bin/uffd-ldapd
EXPOSE 389/tcp
ENTRYPOINT ["/usr/bin/uffd-ldapd"]
CMD ["--socket-address", "0.0.0.0"]

41
cccv-archive-key.asc Normal file
View file

@ -0,0 +1,41 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
JRWMzY9EoBOHB1PubZuzUNIUQeui1vyX
=uRc5
-----END PGP PUBLIC KEY BLOCK-----

1
cccv-archive.list Normal file
View file

@ -0,0 +1 @@
deb [signed-by=/etc/apt/trusted.gpg.d/cccv-archive-key.asc] https://packages.cccv.de/uffd bookworm main

339
uffd-ldapd Executable file
View file

@ -0,0 +1,339 @@
#!/usr/bin/python3
import os
import sys
import socketserver
import logging
import socket
import re
import signal
import click
import requests
from cachecontrol import CacheControl
from cachecontrol.heuristics import ExpiresAfter
import ldapserver
from ldapserver.exceptions import LDAPInvalidCredentials, LDAPInsufficientAccessRights, LDAPUnwillingToPerform
from ldapserver.schema import RFC2307BIS_SCHEMA, RFC2798_SCHEMA
logger = logging.getLogger(__name__)
CUSTOM_SCHEMA = (RFC2307BIS_SCHEMA|RFC2798_SCHEMA).extend(attribute_type_definitions=[
# pylint: disable=line-too-long
"( 1.2.840.113556.1.2.102 NAME 'memberOf' DESC 'Group that the entry belongs to' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 USAGE dSAOperation )"
])
class UffdAPI:
def __init__(self, baseurl, client_id, client_secret, cache_ttl=60):
self.baseurl = baseurl
self.client_id = client_id
self.client_secret = client_secret
self.session = requests.Session()
self.session.auth = (client_id, client_secret)
if cache_ttl:
self.session = CacheControl(self.session, heuristic=ExpiresAfter(seconds=cache_ttl))
def get(self, endpoint, **kwargs):
resp = self.session.get(self.baseurl + endpoint, params=kwargs)
resp.raise_for_status()
return resp.json()
def post(self, endpoint, **kwargs):
resp = self.session.post(self.baseurl + endpoint, data=kwargs)
resp.raise_for_status()
return resp.json()
# pylint: disable=invalid-name,redefined-builtin
def get_users(self, id=None, loginname=None, group=None):
return self.get('/api/v1/getusers', id=id, loginname=loginname, group=group)
def get_groups(self, id=None, name=None, member=None):
return self.get('/api/v1/getgroups', id=id, name=name, member=member)
def check_password(self, loginname, password):
return self.post('/api/v1/checkpassword', loginname=loginname, password=password)
def normalize_user_loginname(loginname):
# The equality matching rule for uid is caseIgnoreMatch. It prepares
# attribute and assertion value according to LDAP stringprep with
# case-folding.
#
# Uffd restricts loginnames to lower-case ASCII letters, digits,
# underscores and dashes. None of these characters are changed or
# rejetced by stringprep with case-folding. The effect stringprep has
# on loginnames is that it adds a leading and a final SPACE character.
#
# The assertion value (the argument to this function) could however contain
# characters that are mapped to SPACE or nothing for example. So we apply
# stringprep to the assertion value and then strip the added leading and
# final SPACE characters. Stringprep case-folds the input string to
# lower-case.
#
# The resulting string can be compared to an actual loginname with simple
# byte-for-byte or codepoint-for-codepoint comparison with or without
# case-folding. It matches if and only if the loginname matches the input
# value according to caseIgnoreMatch.
try:
return ldapserver.rfc4518_stringprep.prepare(loginname, ldapserver.rfc4518_stringprep.MatchingType.CASE_IGNORE_STRING).strip(' ')
except ValueError: # Input value contains prohibited characters
return None
def normalize_group_name(name):
# Currently uffd has no restrictions for group names, but it is planned
# to add restrictions similar to loginname restrictions.
# See https://git.cccv.de/uffd/uffd/-/issues/127
return normalize_user_loginname(name)
class UffdLDAPRequestHandler(ldapserver.LDAPRequestHandler):
subschema = ldapserver.SubschemaSubentry(CUSTOM_SCHEMA, 'cn=Subschema')
# Overwritten before use
api = None
dn_base = None
bind_password = None # if None anonymous reads are allowed
group_filter_regex = None
def do_bind_simple_authenticated(self, dn, password):
dn = self.subschema.DN.from_str(dn)
if dn == self.subschema.DN('cn=service,ou=system') + self.dn_base and password == self.bind_password:
return True
if not dn.is_direct_child_of(self.subschema.DN('ou=users') + self.dn_base) or len(dn[0]) != 1 or dn[0][0].attribute != 'uid':
raise LDAPInvalidCredentials()
try:
if self.api.check_password(loginname=dn[0][0].value, password=password):
return True
except requests.exceptions.HTTPError as exc:
if exc.response.status_code == 403: # We don't have "checkpassword" scope
raise LDAPInsufficientAccessRights() from exc
if exc.response.status_code == 429: # Ratelimited
raise LDAPUnwillingToPerform('Too Many Requests') from exc
raise exc
raise LDAPInvalidCredentials()
supports_sasl_plain = True
def do_bind_sasl_plain(self, identity, password, authzid=None):
if authzid is not None and identity != authzid:
raise LDAPInvalidCredentials()
try:
if self.api.check_password(loginname=identity, password=password):
return True
except requests.exceptions.HTTPError as exc:
if exc.response.status_code == 403: # We don't have "checkpassword" scope
raise LDAPInsufficientAccessRights() from exc
if exc.response.status_code == 429: # Ratelimited
raise LDAPUnwillingToPerform('Too Many Requests') from exc
raise exc
raise LDAPInvalidCredentials()
def do_search(self, baseobj, scope, filterobj):
yield from super().do_search(baseobj, scope, filterobj)
if self.bind_object or self.bind_password is None:
yield from self.do_search_static()
yield from self.do_search_users(baseobj, scope, filterobj)
yield from self.do_search_groups(baseobj, scope, filterobj)
def do_search_static(self):
base_attrs = {
'objectClass': ['top', 'dcObject', 'organization'],
'structuralObjectClass': ['organization'],
}
for rdnassertion in self.dn_base[0]: # pylint: disable=unsubscriptable-object
base_attrs[rdnassertion.attribute] = [rdnassertion.value]
yield self.subschema.ObjectEntry(self.dn_base, **base_attrs)
yield self.subschema.ObjectEntry(self.subschema.DN('ou=users') + self.dn_base,
ou=['users'],
objectClass=['top', 'organizationalUnit'],
structuralObjectClass=['organizationalUnit'],
)
yield self.subschema.ObjectEntry(self.subschema.DN('ou=groups') + self.dn_base,
ou=['groups'],
objectClass=['top', 'organizationalUnit'],
structuralObjectClass=['organizationalUnit'],
)
yield self.subschema.ObjectEntry(self.subschema.DN('ou=system') + self.dn_base,
ou=['system'],
objectClass=['top', 'organizationalUnit'],
structuralObjectClass=['organizationalUnit'],
)
yield self.subschema.ObjectEntry(self.subschema.DN('cn=service,ou=system') + self.dn_base,
cn=['service'],
objectClass=['top', 'organizationalRole', 'simpleSecurityObject'],
structuralObjectClass=['organizationalRole'],
)
def do_search_users(self, baseobj, scope, filterobj):
template = self.subschema.EntryTemplate(self.subschema.DN(self.dn_base, ou='users'), 'uid',
structuralObjectClass=['inetorgperson'],
objectClass=['top', 'inetorgperson', 'organizationalperson', 'person', 'posixaccount'],
cn=ldapserver.WILDCARD,
displayname=ldapserver.WILDCARD,
givenname=ldapserver.WILDCARD,
homeDirectory=ldapserver.WILDCARD,
mail=ldapserver.WILDCARD,
sn=[' '],
uid=ldapserver.WILDCARD,
uidNumber=ldapserver.WILDCARD,
memberOf=ldapserver.WILDCARD,
)
if not template.match_search(baseobj, scope, filterobj):
return
constraints = template.extract_search_constraints(baseobj, scope, filterobj)
request_params = {}
if 'uid' in constraints:
request_params = {'loginname': normalize_user_loginname(constraints['uid'][0])}
elif 'uidnumber' in constraints:
request_params = {'id': constraints['uidnumber'][0]}
elif 'memberof' in constraints:
for value in constraints['memberof']:
if value.is_direct_child_of(self.subschema.DN(self.dn_base, ou='groups')) and value.object_attribute == 'cn':
request_params = {'group': normalize_group_name(value.object_value)}
break
if 'group' in request_params and not self.group_filter_regex.match(request_params['group']):
return
for user in self.api.get_users(**request_params):
yield template.create_entry(user['loginname'],
cn=[user['displayname']],
displayname=[user['displayname']],
givenname=[user['displayname']],
homeDirectory=['/home/'+user['loginname']],
mail=[user['email']],
uid=[user['loginname']],
uidNumber=[user['id']],
memberOf=[self.subschema.DN(self.subschema.DN(self.dn_base, ou='groups'), cn=group)
for group in user['groups']
if self.group_filter_regex.match(group)],
)
def do_search_groups(self, baseobj, scope, filterobj):
template = self.subschema.EntryTemplate(self.subschema.DN(self.dn_base, ou='groups'), 'cn',
structuralObjectClass=['groupOfUniqueNames'],
objectClass=['top', 'groupOfUniqueNames', 'posixGroup'],
cn=ldapserver.WILDCARD,
description=[' '],
gidNumber=ldapserver.WILDCARD,
uniqueMember=ldapserver.WILDCARD,
)
if not template.match_search(baseobj, scope, filterobj):
return
constraints = template.extract_search_constraints(baseobj, scope, filterobj)
request_params = {}
if 'cn' in constraints:
request_params = {'name': normalize_group_name(constraints['cn'][0])}
elif 'gidnumber' in constraints:
request_params = {'id': constraints['gidnumber'][0]}
elif 'uniquemember' in constraints:
for value in constraints['uniquemember']:
if value.is_direct_child_of(self.subschema.DN(self.dn_base, ou='users')) and value.object_attribute == 'uid':
request_params = {'member': normalize_user_loginname(value.object_value)}
break
if 'name' in request_params and not self.group_filter_regex.match(request_params['name']):
return
for group in self.api.get_groups(**request_params):
if not self.group_filter_regex.match(group['name']):
continue
yield template.create_entry(group['name'],
cn=[group['name']],
gidNumber=[group['id']],
uniqueMember=[self.subschema.DN(self.subschema.DN(self.dn_base, ou='users'), uid=user) for user in group['members']],
)
def make_requesthandler(api, dn_base, bind_password=None, group_filter_regex=None):
class RequestHandler(UffdLDAPRequestHandler):
pass
dn_base = RequestHandler.subschema.DN.from_str(dn_base)
RequestHandler.api = api
RequestHandler.dn_base = dn_base
RequestHandler.bind_password = bind_password.encode() if bind_password else None
RequestHandler.group_filter_regex = re.compile(group_filter_regex) if group_filter_regex else re.compile('')
return RequestHandler
class FilenoUnixStreamServer(socketserver.UnixStreamServer):
def __init__(self, fd, RequestHandlerClass, bind_and_activate=True):
self.server_fd = fd
super().__init__(None, RequestHandlerClass, bind_and_activate=bind_and_activate)
def server_bind(self):
self.socket.close() # UnixStreamServer.__init__ creates an unbound socket
self.socket = socket.fromfd(self.server_fd, socket.AF_UNIX, socket.SOCK_STREAM)
self.server_address = self.socket.getsockname()
class ThreadingFilenoUnixStreamServer(socketserver.ThreadingMixIn, FilenoUnixStreamServer):
pass
def cleanup_unix_socket(path):
if not os.path.exists(path):
return
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
conn.connect(path)
except ConnectionRefusedError:
os.remove(path)
conn.close()
def parse_network_address(addr):
port = '389'
if addr.startswith('['):
addr, remainder = addr[1:].split(']', 1)
if remainder.startswith(':'):
port = remainder[1:]
elif ':' in addr:
addr, port = addr.split(':')
return addr, port
class StdoutFilter(logging.Filter):
def filter(self, record):
return record.levelno <= logging.INFO
def sigterm_handler(signum, frame):
logger.info("Received SIGTERM, shutting down gracefully ...")
sys.exit(0)
# pylint: disable=line-too-long
@click.command(help='LDAP proxy for integrating LDAP service with uffd SSO. Supports user and group searches and as well as binds with user passwords.')
@click.option('--socket-address', help='Host and port "ip:port" to listen on')
@click.option('--socket-path', type=click.Path(), help='Path for UNIX domain socket')
@click.option('--socket-fd', type=int, help='Use fd number as server socket (alternative to --socket-path)')
@click.option('--api-url', required=True, help='Uffd base URL without API prefix or trailing slash (e.g. https://example.com)')
@click.option('--api-user', required=True, help='API user/client id')
@click.option('--api-secret', required=True, help='API secret, do not set this on the command-line, use environment variable SERVER_API_SECRET instead')
@click.option('--cache-ttl', default=60, help='Time-to-live for API response caching in seconds')
@click.option('--base-dn', required=True, help='Base DN for user, group and system objects. E.g. "dc=example,dc=com"')
@click.option('--bind-password', help='Authentication password for the service connection to LDAP. Bind DN is always "cn=service,ou=system,BASEDN". If set, anonymous access is disabled.')
@click.option('--group-filter-regex', help='Python regular expression that group names must match for the group to be visible to LDAP clients')
def main(socket_address, socket_path, socket_fd, api_url, api_user, api_secret, cache_ttl, base_dn, bind_password, group_filter_regex):
# Register signal handler for proper SIGTERM handling
signal.signal(signal.SIGTERM, sigterm_handler)
# pylint: disable=too-many-locals
if (socket_address is not None) \
+ (socket_path is not None) \
+ (socket_fd is not None) != 1:
raise click.ClickException('Either --socket-address, --socket-path or --socket-fd must be specified')
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setLevel(logging.INFO)
stdout_handler.addFilter(StdoutFilter())
stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setLevel(logging.WARNING)
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
root_logger.addHandler(stdout_handler)
root_logger.addHandler(stderr_handler)
api = UffdAPI(api_url, api_user, api_secret, cache_ttl)
RequestHandler = make_requesthandler(api, base_dn, bind_password, group_filter_regex)
if socket_address is not None:
host, port = parse_network_address(socket_address)
server = socketserver.ThreadingTCPServer((host, int(port)), RequestHandler)
elif socket_path is not None:
cleanup_unix_socket(socket_path)
server = socketserver.ThreadingUnixStreamServer(socket_path, RequestHandler)
else:
server = ThreadingFilenoUnixStreamServer(socket_fd, RequestHandler)
server.serve_forever()
if __name__ == '__main__':
# Pylint does not seem to understand click's decorators
# pylint: disable=unexpected-keyword-arg,no-value-for-parameter
main(auto_envvar_prefix='SERVER')