From 27d78f47191051d440fd37227050275c81bd2923 Mon Sep 17 00:00:00 2001 From: Nis Wechselberg Date: Mon, 9 Jun 2025 16:50:47 +0200 Subject: [PATCH] Added initial container definition for uffd-ldapd Signed-off-by: Nis Wechselberg --- Containerfile | 21 +++ cccv-archive-key.asc | 41 ++++++ cccv-archive.list | 1 + uffd-ldapd | 339 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 402 insertions(+) create mode 100644 Containerfile create mode 100644 cccv-archive-key.asc create mode 100644 cccv-archive.list create mode 100755 uffd-ldapd diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..4aaf596 --- /dev/null +++ b/Containerfile @@ -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"] diff --git a/cccv-archive-key.asc b/cccv-archive-key.asc new file mode 100644 index 0000000..1dc3423 --- /dev/null +++ b/cccv-archive-key.asc @@ -0,0 +1,41 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +JRWMzY9EoBOHB1PubZuzUNIUQeui1vyX +=uRc5 +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/cccv-archive.list b/cccv-archive.list new file mode 100644 index 0000000..fb9ff51 --- /dev/null +++ b/cccv-archive.list @@ -0,0 +1 @@ +deb [signed-by=/etc/apt/trusted.gpg.d/cccv-archive-key.asc] https://packages.cccv.de/uffd bookworm main diff --git a/uffd-ldapd b/uffd-ldapd new file mode 100755 index 0000000..516d821 --- /dev/null +++ b/uffd-ldapd @@ -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')