From 0559379b4234d49f1269437a786d740dd6223768 Mon Sep 17 00:00:00 2001 From: kat witch Date: Tue, 21 Sep 2021 00:54:05 +0100 Subject: [PATCH] services/murmur: Integrate with services/murmur-ldap. --- config/hosts/kyouko.nix | 1 + config/services/murmur-ldap/LDAPauth.py | 884 ++++++++++++++++++++++++ config/services/murmur-ldap/default.nix | 80 +++ config/services/murmur.nix | 8 + config/services/openldap/default.nix | 8 +- 5 files changed, 975 insertions(+), 6 deletions(-) create mode 100644 config/services/murmur-ldap/LDAPauth.py create mode 100644 config/services/murmur-ldap/default.nix diff --git a/config/hosts/kyouko.nix b/config/hosts/kyouko.nix index 6c12e9e3..2fdbfd7f 100644 --- a/config/hosts/kyouko.nix +++ b/config/hosts/kyouko.nix @@ -14,6 +14,7 @@ with lib; services.logrotate services.synapse services.murmur + services.murmur-ldap services.nginx services.postgres services.prosody diff --git a/config/services/murmur-ldap/LDAPauth.py b/config/services/murmur-ldap/LDAPauth.py new file mode 100644 index 00000000..2da0e06c --- /dev/null +++ b/config/services/murmur-ldap/LDAPauth.py @@ -0,0 +1,884 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Copyright (C) 2011 Benjamin Jemlich +# Copyright (C) 2011 Nathaniel Kofalt +# Copyright (C) 2013 Stefan Hacker +# Copyright (C) 2014 Dominik George +# Copyright (C) 2020 Andreas Valder +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# - Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# - Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# - Neither the name of the Mumble Developers nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +# This script will let you authenticate Murmur against an LDAP tree. +# Note that you should have a reasonable understanding of LDAP before trying to use this script. +# +# Unfortunately, LDAP is a rather complex concept / protocol / software suite. +# So if you're not already experienced with LDAP, the Mumble team may be unable to assist you. +# Unless you already have an existing LDAP tree, you may want to authenticate your users another way. +# However, LDAP has the advantage of being extremely scalable, flexible, and resilient. +# This is probably a decent choice for larger-scale deployments (code review this script first!) +# +# There are some excellent resources to get you started: +# Wikipedia article: http://en.wikipedia.org/wiki/LDAP +# OpenLDAP intro: http://www.openldap.org/doc/admin24/intro.html +# LDAP on Debian: http://techpubs.spinlocksolutions.com/dklar/ldap.html +# IRC Chat room: Channel #ldap on irc.freenode.net +# +# Configuring this to hit LDAP correctly can be a little tricky. +# This is largely due to the numerous ways you can store user information in LDAP. +# The example configuration is probably not the best way to do things; it's just a simple setup. +# +# The group-membership code will have to be expanded if you want multiple groups allowed, etc. +# This is just a simple example. +# +# In this configuration, I use a really simple groupOfUniqueNames and OU of inetOrgPersons. +# The tree already uses the "uid" attribute for usernames, so roomNumber was used to store UID. +# Note that mumble needs a username, password, and unique UID for each user. +# You can definitely set things up differently; this is a bit of a kludge. +# +# Here is the tree layout used in the example config: +# dc=example,dc=com (organization) +# ou=Groups (organizationalUnit) +# cn=mumble (groupOfUniqueNames) +# "uniqueMember: uid=user1,dc=example,dc=com" +# "uniqueMember: uid=user2,dc=example,dc=com" +# ou=Users (organizationalUnit) +# uid=user1 (inetOrgPerson) +# "userPassword: {SHA}password-hash" +# "displayName: User One" +# "roomNumber: 1" +# uid=user2 (inetOrgPerson) +# "userPassword: {SHA}password-hash" +# "displayName: User Two" +# "roomNumber: 2" +# uid=user3 (inetOrgPerson) +# "userPassword: {SHA}password-hash" +# "displayName: User Three" +# "roomNumber: 3" +# +# How the script operates: +# First, the script will attempt to "bind" with the user's credentials. +# If the bind fails, the username/password combination is rejected. +# Second, it optionally checks for a group membership. +# With groups off, all three users are let in; with groups on, only user1 & user2 are allowed. +# Finally, it optionally logs in the user with a separate "display_attr" name. +# This allows user1 to log in with the USERNAME "user1" but is displayed in mumble as "User One". +# +# If you use the bind_dn option, the script will bind with the specified DN +# and check for the existence of user and (optionally) the group membership +# before it binds with the username/password. This allows you to use a server +# which only allows authentication by end users without any search +# permissions. It also allows you to set the reject_on_miss option to false +# and let login IDs not found in LDAP fall-through to an alternate +# authentication scheme. +# +# Requirements: +# * python >=3.8 (maybe 3.6 is enough but it wasn't tested) and the following python modules: +# * ice-python +# * python-ldap +# * daemon (when run as a daemon) +# If you are using Ubuntu/Debian (only Ubuntu 20.04 was tested) the following packages provide these: +# * python3 +# * python3-zeroc-ice +# * python3-ldap +# * python3-daemon +# * zeroc-ice-slice + +import sys +import ldap +import Ice +import _thread +import urllib.request, urllib.error, urllib.parse +import logging +import configparser + +from threading import Timer +from optparse import OptionParser +from logging import (debug, + info, + warning, + error, + critical, + exception, + getLogger) + +def x2bool(s): + """Helper function to convert strings from the config to bool""" + if isinstance(s, bool): + return s + elif isinstance(s, str): + return s.lower() in ['1', 'true'] + raise ValueError() + +# +#--- Default configuration values +# +cfgfile = 'LDAPauth.ini' +default = { 'ldap':(('ldap_uri', str, 'ldap://127.0.0.1'), + ('bind_dn', str, ''), + ('bind_pass', str, ''), + ('users_dn', str, 'ou=Users,dc=example,dc=org'), + ('discover_dn', x2bool, True), + ('username_attr', str, 'uid'), + ('number_attr', str, 'RoomNumber'), + ('display_attr', str, 'displayName'), + ('group_dn', str, ''), + ('group_attr', str, ''), + ('provide_info', x2bool, False), + ('mail_attr', str, 'mail'), + ('provide_users', x2bool, False), + ('use_start_tls', x2bool, False)), + + 'user':(('id_offset', int, 1000000000), + ('reject_on_error', x2bool, True), + ('reject_on_miss', x2bool, True)), + + 'ice':(('host', str, '127.0.0.1'), + ('port', int, 6502), + ('slice', str, 'Murmur.ice'), + ('secret', str, ''), + ('watchdog', int, 30)), + + 'iceraw':None, + + 'murmur':(('servers', lambda x:list(map(int, x.split(','))), []),), + 'glacier':(('enabled', x2bool, False), + ('user', str, 'ldapauth'), + ('password', str, 'secret'), + ('host', str, 'localhost'), + ('port', int, '4063')), + + 'log':(('level', int, logging.DEBUG), + ('file', str, 'LDAPauth.log'))} + +# +#--- Helper classes +# +class config(object): + """ + Small abstraction for config loading + """ + + def __init__(self, filename = None, default = None): + if not filename or not default: return + cfg = configparser.ConfigParser() + cfg.optionxform = str + cfg.read(filename) + + for h,v in default.items(): + if not v: + # Output this whole section as a list of raw key/value tuples + try: + self.__dict__[h] = cfg.items(h) + except configparser.NoSectionError: + self.__dict__[h] = [] + else: + self.__dict__[h] = config() + for name, conv, vdefault in v: + try: + self.__dict__[h].__dict__[name] = conv(cfg.get(h, name)) + except (ValueError, configparser.NoSectionError, configparser.NoOptionError): + self.__dict__[h].__dict__[name] = vdefault + + +def do_main_program(): + # + #--- Authenticator implementation + # All of this has to go in here so we can correctly daemonize the tool + # without loosing the file descriptors opened by the Ice module + slicedir = Ice.getSliceDir() + if not slicedir: + slicedir = ["-I/usr/share/Ice/slice", "-I/usr/share/slice"] + else: + slicedir = ['-I' + slicedir] + Ice.loadSlice('', slicedir + [cfg.ice.slice]) + import Murmur + + class LDAPAuthenticatorApp(Ice.Application): + def run(self, args): + self.shutdownOnInterrupt() + + if not self.initializeIceConnection(): + return 1 + + if cfg.ice.watchdog > 0: + self.failedWatch = True + self.checkConnection() + + # Serve till we are stopped + self.communicator().waitForShutdown() + self.watchdog.cancel() + + if self.interrupted(): + warning('Caught interrupt, shutting down') + + return 0 + + def initializeIceConnection(self): + """ + Establishes the two-way Ice connection and adds the authenticator to the + configured servers + """ + ice = self.communicator() + + if cfg.ice.secret: + debug('Using shared ice secret') + ice.getImplicitContext().put("secret", cfg.ice.secret) + elif not cfg.glacier.enabled: + warning('Consider using an ice secret to improve security') + + if cfg.glacier.enabled: + #info('Connecting to Glacier2 server (%s:%d)', glacier_host, glacier_port) + error('Glacier support not implemented yet') + #TODO: Implement this + + info('Connecting to Ice server (%s:%d)', cfg.ice.host, cfg.ice.port) + base = ice.stringToProxy('Meta:tcp -h %s -p %d' % (cfg.ice.host, cfg.ice.port)) + self.meta = Murmur.MetaPrx.uncheckedCast(base) + + adapter = ice.createObjectAdapterWithEndpoints('Callback.Client', 'tcp -h %s' % cfg.ice.host) + adapter.activate() + + metacbprx = adapter.addWithUUID(metaCallback(self)) + self.metacb = Murmur.MetaCallbackPrx.uncheckedCast(metacbprx) + + authprx = adapter.addWithUUID(LDAPAuthenticator()) + self.auth = Murmur.ServerUpdatingAuthenticatorPrx.uncheckedCast(authprx) + + return self.attachCallbacks() + + def attachCallbacks(self, quiet = False): + """ + Attaches all callbacks for meta and authenticators + """ + + # Ice.ConnectionRefusedException + #debug('Attaching callbacks') + try: + if not quiet: info('Attaching meta callback') + + self.meta.addCallback(self.metacb) + + for server in self.meta.getBootedServers(): + if not cfg.murmur.servers or server.id() in cfg.murmur.servers: + if not quiet: info('Setting authenticator for virtual server %d', server.id()) + server.setAuthenticator(self.auth) + + except (Murmur.InvalidSecretException, Ice.UnknownUserException, Ice.ConnectionRefusedException) as e: + if isinstance(e, Ice.ConnectionRefusedException): + error('Server refused connection') + elif isinstance(e, Murmur.InvalidSecretException) or \ + isinstance(e, Ice.UnknownUserException) and (e.unknown == 'Murmur::InvalidSecretException'): + error('Invalid ice secret') + else: + # We do not actually want to handle this one, re-raise it + raise e + + self.connected = False + return False + + self.connected = True + return True + + def checkConnection(self): + """ + Tries reapplies all callbacks to make sure the authenticator + survives server restarts and disconnects. + """ + #debug('Watchdog run') + + try: + if not self.attachCallbacks(quiet = not self.failedWatch): + self.failedWatch = True + else: + self.failedWatch = False + except Ice.Exception as e: + error('Failed connection check, will retry in next watchdog run (%ds)', cfg.ice.watchdog) + debug(str(e)) + self.failedWatch = True + + # Renew the timer + self.watchdog = Timer(cfg.ice.watchdog, self.checkConnection) + self.watchdog.start() + + def checkSecret(func): + """ + Decorator that checks whether the server transmitted the right secret + if a secret is supposed to be used. + """ + if not cfg.ice.secret: + return func + + def newfunc(*args, **kws): + if 'current' in kws: + current = kws["current"] + else: + current = args[-1] + + if not current or 'secret' not in current.ctx or current.ctx['secret'] != cfg.ice.secret: + error('Server transmitted invalid secret. Possible injection attempt.') + raise Murmur.InvalidSecretException() + + return func(*args, **kws) + + return newfunc + + def fortifyIceFu(retval = None, exceptions = (Ice.Exception,)): + """ + Decorator that catches exceptions,logs them and returns a safe retval + value. This helps preventing the authenticator getting stuck in + critical code paths. Only exceptions that are instances of classes + given in the exceptions list are not caught. + + The default is to catch all non-Ice exceptions. + """ + def newdec(func): + def newfunc(*args, **kws): + try: + return func(*args, **kws) + except Exception as e: + catch = True + for ex in exceptions: + if isinstance(e, ex): + catch = False + break + + if catch: + critical('Unexpected exception caught') + exception(e) + return retval + raise + + return newfunc + return newdec + + class metaCallback(Murmur.MetaCallback): + def __init__(self, app): + Murmur.MetaCallback.__init__(self) + self.app = app + + @fortifyIceFu() + @checkSecret + def started(self, server, current = None): + """ + This function is called when a virtual server is started + and makes sure an authenticator gets attached if needed. + """ + if not cfg.murmur.servers or server.id() in cfg.murmur.servers: + info('Setting authenticator for virtual server %d', server.id()) + try: + server.setAuthenticator(app.auth) + # Apparently this server was restarted without us noticing + except (Murmur.InvalidSecretException, Ice.UnknownUserException) as e: + if hasattr(e, "unknown") and e.unknown != "Murmur::InvalidSecretException": + # Special handling for Murmur 1.2.2 servers with invalid slice files + raise e + + error('Invalid ice secret') + return + else: + debug('Virtual server %d got started', server.id()) + + @fortifyIceFu() + @checkSecret + def stopped(self, server, current = None): + """ + This function is called when a virtual server is stopped + """ + if self.app.connected: + # Only try to output the server id if we think we are still connected to prevent + # flooding of our thread pool + try: + if not cfg.murmur.servers or server.id() in cfg.murmur.servers: + info('Authenticated virtual server %d got stopped', server.id()) + else: + debug('Virtual server %d got stopped', server.id()) + return + except Ice.ConnectionRefusedException: + self.app.connected = False + + debug('Server shutdown stopped a virtual server') + + if cfg.user.reject_on_error: # Python 2.4 compat + authenticateFortifyResult = (-1, None, None) + else: + authenticateFortifyResult = (-2, None, None) + + class LDAPAuthenticator(Murmur.ServerUpdatingAuthenticator): + def __init__(self): + Murmur.ServerUpdatingAuthenticator.__init__(self) + self.name_uid_cache = dict() + + @fortifyIceFu(authenticateFortifyResult) + @checkSecret + def authenticate(self, name, pw, certlist, certhash, strong, current = None): + """ + This function is called to authenticate a user + """ + + # Search for the user in the database + FALL_THROUGH = -2 + AUTH_REFUSED = -1 + + # SuperUser is a special login. + if name == 'SuperUser': + debug('Forced fall through for SuperUser') + return (FALL_THROUGH, None, None) + + # Otherwise, let's check the LDAP server. + uid = None + + if cfg.ldap.use_start_tls: + # try StartTLS: global options + debug('use_start_tls is set, setting global option TLS_REQCERT = never') + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) + + ldap_trace = 0 # Change to 1 for more verbose trace + ldap_conn = ldap.initialize(cfg.ldap.ldap_uri, ldap_trace) + + if cfg.ldap.use_start_tls: + # try StartTLS: connection specific options + debug('use_start_tls is set, setting connection options X_TLS_*') + ldap_conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3) + ldap_conn.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND) + ldap_conn.set_option(ldap.OPT_X_TLS_DEMAND, True) + try: + ldap_conn.start_tls_s() + except Exception as e: + warning('could not initiate StartTLS, e = ' + str(e)) + return (AUTH_REFUSED, None, None) + + if cfg.ldap.bind_dn: + # Bind the functional account to search the directory. + bind_dn = cfg.ldap.bind_dn + bind_pass = cfg.ldap.bind_pass + try: + debug('try to connect to ldap (bind_dn will be used)') + ldap_conn.bind_s(bind_dn, bind_pass) + except ldap.INVALID_CREDENTIALS: + ldap_conn.unbind() + warning('Invalid credentials for bind_dn=' + bind_dn) + return (AUTH_REFUSED, None, None) + elif cfg.ldap.discover_dn: + # Use anonymous bind to discover the DN + try: + ldap_conn.bind_s() + except ldap.INVALID_CREDENTIALS: + ldap_conn.unbind() + warning('Failed anomymous bind for discovering DN') + return (AUTH_REFUSED, None, None) + + else: + # Prevent anonymous authentication. + if not pw: + warning("No password supplied for user " + name) + return (AUTH_REFUSED, None, None) + + # Bind the user account to search the directory. + bind_dn = "%s=%s,%s" % (cfg.ldap.username_attr, name, cfg.ldap.users_dn) + bind_pass = pw + try: + ldap_conn.bind_s(bind_dn, bind_pass) + except ldap.INVALID_CREDENTIALS: + ldap_conn.unbind() + warning('User ' + name + ' failed with invalid credentials') + return (AUTH_REFUSED, None, None) + + # Search for the user. + username_to_try = name.split(".")[0] if "." in name else name + res = ldap_conn.search_s(cfg.ldap.users_dn, ldap.SCOPE_SUBTREE, '(%s=%s)' % (cfg.ldap.username_attr, username_to_try), [cfg.ldap.number_attr, cfg.ldap.display_attr]) + if len(res) == 0: + warning("User " + username_to_try + " not found, input was " + name) + if cfg.user.reject_on_miss: + return (AUTH_REFUSED, None, None) + else: + return (FALL_THROUGH, None, None) + match = res[0] #Only interested in the first result, as there should only be one match + + # Parse the user information. + uid = int(match[1][cfg.ldap.number_attr][0]) + displayName = match[1][cfg.ldap.display_attr][0].decode() + user_dn = match[0] + debug('User match found, display "' + displayName + '" with UID ' + repr(uid)) + + # Optionally check groups. + if cfg.ldap.group_dn != "" : + debug('Checking group membership for ' + name) + + #Search for user in group + res = ldap_conn.search_s(cfg.ldap.group_dn, ldap.SCOPE_SUBTREE, '(%s=%s)' % (cfg.ldap.group_attr, user_dn), [cfg.ldap.number_attr, cfg.ldap.display_attr]) + + # Check if the user is a member of the group + if len(res) < 1: + debug('User ' + name + ' failed with no group membership') + return (AUTH_REFUSED, None, None) + + # Second bind to test user credentials if using bind_dn or discover_dn. + if cfg.ldap.bind_dn or cfg.ldap.discover_dn: + # Prevent anonymous authentication. + if not pw: + warning("No password supplied for user " + name) + return (AUTH_REFUSED, None, None) + + bind_dn = user_dn + bind_pass = pw + try: + ldap_conn.bind_s(bind_dn, bind_pass) + except ldap.INVALID_CREDENTIALS: + ldap_conn.unbind() + warning('User ' + name + ' failed with wrong password') + return (AUTH_REFUSED, None, None) + + # Unbind and close connection. + ldap_conn.unbind() + + # If we get here, the login is correct. + # Add the user/id combo to cache, then accept: + self.name_uid_cache[displayName] = uid + debug("Login accepted for " + name) + return (uid + cfg.user.id_offset, displayName, []) + + @fortifyIceFu((False, None)) + @checkSecret + def getInfo(self, id, current = None): + """ + Gets called to fetch user specific information + """ + + if not cfg.ldap.provide_info: + # We do not expose any additional information so always fall through + debug('getInfo for %d -> denied', id) + return (False, None) + + ldap_conn = ldap.initialize(cfg.ldap.ldap_uri, 0) + + # Bind if configured, else do explicit anonymous bind + if cfg.ldap.bind_dn and cfg.ldap.bind_pass: + ldap_conn.simple_bind_s(cfg.ldap.bind_dn, cfg.ldap.bind_pass) + else: + ldap_conn.simple_bind_s() + + name = self.idToName(id, current) + + res = ldap_conn.search_s(cfg.ldap.users_dn, + ldap.SCOPE_SUBTREE, + '(%s=%s)' % (cfg.ldap.display_attr, name), + [cfg.ldap.display_attr, + cfg.ldap.mail_attr + ]) + + #If user found, return info + if len(res) == 1: + info = {} + + if cfg.ldap.mail_attr in res[0][1]: + info[Murmur.UserInfo.UserEmail] = res[0][1][cfg.ldap.mail_attr][0].decode() + + debug('getInfo %s -> %s', name, repr(info)) + return (True, info) + else: + debug('getInfo %s -> ?', name) + return (False, None) + + + + @fortifyIceFu(-2) + @checkSecret + def nameToId(self, name, current = None): + """ + Gets called to get the id for a given username + """ + FALL_THROUGH = -2 + + if name == 'SuperUser': + debug('nameToId SuperUser -> forced fall through') + return FALL_THROUGH + + if name in self.name_uid_cache: + uid = self.name_uid_cache[name] + cfg.user.id_offset + debug("nameToId %s (cache) -> %d", name, uid) + return uid + + ldap_conn = ldap.initialize(cfg.ldap.ldap_uri, 0) + + # Bind if configured, else do explicit anonymous bind + if cfg.ldap.bind_dn and cfg.ldap.bind_pass: + ldap_conn.simple_bind_s(cfg.ldap.bind_dn, cfg.ldap.bind_pass) + else: + ldap_conn.simple_bind_s() + + res = ldap_conn.search_s(cfg.ldap.users_dn, ldap.SCOPE_SUBTREE, '(%s=%s)' % (cfg.ldap.display_attr, name), [cfg.ldap.number_attr]) + + #If user found, return the ID + if len(res) == 1: + uid = int(res[0][1][cfg.ldap.number_attr][0]) + cfg.user.id_offset + debug('nameToId %s -> %d', name, uid) + else: + debug('nameToId %s -> ?', name) + return FALL_THROUGH + + return uid + + + @fortifyIceFu("") + @checkSecret + def idToName(self, id, current = None): + """ + Gets called to get the username for a given id + """ + + FALL_THROUGH = "" + + # Make sure the ID is in our range and transform it to the actual LDAP user id + if id < cfg.user.id_offset: + debug('idToName %d -> fall through', id) + return FALL_THROUGH + + ldapid = id - cfg.user.id_offset + + for name, uid in self.name_uid_cache.items(): + if uid == ldapid: + if name == 'SuperUser': + debug('idToName %d -> "SuperUser" catched', id) + return FALL_THROUGH + + debug('idToName %d -> "%s"', id, name) + return name + + debug('idToName %d -> ?', id) + return FALL_THROUGH + + + @fortifyIceFu("") + @checkSecret + def idToTexture(self, id, current = None): + """ + Gets called to get the corresponding texture for a user + """ + + FALL_THROUGH = "" + debug('idToTexture %d -> fall through', id) + return FALL_THROUGH + + @fortifyIceFu(-2) + @checkSecret + def registerUser(self, name, current = None): + """ + Gets called when the server is asked to register a user. + """ + + FALL_THROUGH = -2 + debug('registerUser "%s" -> fall through', name) + return FALL_THROUGH + + @fortifyIceFu(-1) + @checkSecret + def unregisterUser(self, id, current = None): + """ + Gets called when the server is asked to unregister a user. + """ + + FALL_THROUGH = -1 + # Return -1 to fall through to internal server database, we will not modify the LDAP directory + # but we can make murmur delete all additional information it got this way. + debug('unregisterUser %d -> fall through', id) + return FALL_THROUGH + + @fortifyIceFu({}) + @checkSecret + def getRegisteredUsers(self, filter, current = None): + """ + Returns a list of usernames in the LDAP directory which contain + filter as a substring. + """ + FALL_THROUGH = {} + + if not cfg.ldap.provide_users: + # Fall through if not configured to provide user list + debug('getRegisteredUsers -> fall through') + return FALL_THROUGH + + ldap_conn = ldap.initialize(cfg.ldap.ldap_uri, 0) + + # Bind if configured, else do explicit anonymous bind + if cfg.ldap.bind_dn and cfg.ldap.bind_pass: + ldap_conn.simple_bind_s(cfg.ldap.bind_dn, cfg.ldap.bind_pass) + else: + ldap_conn.simple_bind_s() + + if filter: + res = ldap_conn.search_s(cfg.ldap.users_dn, ldap.SCOPE_SUBTREE, '(&(uid=*)(%s=*%s*))' % (cfg.ldap.display_attr, filter), [cfg.ldap.number_attr, cfg.ldap.display_attr]) + else: + res = ldap_conn.search_s(cfg.ldap.users_dn, ldap.SCOPE_SUBTREE, '(uid=*)', [cfg.ldap.number_attr, cfg.ldap.display_attr]) + + # Build result dict + users = {} + for dn, attrs in res: + if cfg.ldap.number_attr in attrs and cfg.ldap.display_attr in attrs: + uid = int(attrs[cfg.ldap.number_attr][0]) + cfg.user.id_offset + name = attrs[cfg.ldap.display_attr][0] + users[uid] = name + debug('getRegisteredUsers %s -> %s', filter, repr(users)) + return users + + @fortifyIceFu(-1) + @checkSecret + def setInfo(self, id, info, current = None): + """ + Gets called when the server is supposed to save additional information + about a user to his database + """ + + FALL_THROUGH = -1 + # Return -1 to fall through to the internal server handler. We do not store + # any information in LDAP + debug('setInfo %d -> fall through', id) + return FALL_THROUGH + + @fortifyIceFu(-1) + @checkSecret + def setTexture(self, id, texture, current = None): + """ + Gets called when the server is asked to update the user texture of a user + """ + FALL_THROUGH = -1 + + # We do not store textures in LDAP + debug('setTexture %d -> fall through', id) + return FALL_THROUGH + + class CustomLogger(Ice.Logger): + """ + Logger implementation to pipe Ice log messages into + out own log + """ + + def __init__(self): + Ice.Logger.__init__(self) + self._log = getLogger('Ice') + + def _print(self, message): + self._log.info(message) + + def trace(self, category, message): + self._log.debug('Trace %s: %s', category, message) + + def warning(self, message): + self._log.warning(message) + + def error(self, message): + self._log.error(message) + + # + #--- Start of authenticator + # + info('Starting LDAP mumble authenticator') + initdata = Ice.InitializationData() + initdata.properties = Ice.createProperties([], initdata.properties) + for prop, val in cfg.iceraw: + initdata.properties.setProperty(prop, val) + + initdata.properties.setProperty('Ice.ImplicitContext', 'Shared') + initdata.properties.setProperty('Ice.Default.EncodingVersion', '1.0') + initdata.logger = CustomLogger() + + app = LDAPAuthenticatorApp() + state = app.main(sys.argv[:1], initData = initdata) + info('Shutdown complete') + +# +#--- Start of program +# +if __name__ == '__main__': + # Parse commandline options + parser = OptionParser() + parser.add_option('-i', '--ini', + help = 'load configuration from INI', default = cfgfile) + parser.add_option('-v', '--verbose', action='store_true', dest = 'verbose', + help = 'verbose output [default]', default = True) + parser.add_option('-q', '--quiet', action='store_false', dest = 'verbose', + help = 'only error output') + parser.add_option('-d', '--daemon', action='store_true', dest = 'force_daemon', + help = 'run as daemon', default = False) + parser.add_option('-a', '--app', action='store_true', dest = 'force_app', + help = 'do not run as daemon', default = False) + (option, args) = parser.parse_args() + + if option.force_daemon and option.force_app: + parser.print_help() + sys.exit(1) + + # Load configuration + try: + cfg = config(option.ini, default) + except Exception as e: + print('Fatal error, could not load config file from "%s"' % cfgfile, file=sys.stderr) + sys.exit(1) + + + # Initialize logger + if cfg.log.file: + try: + logfile = open(cfg.log.file, 'a') + except IOError as e: + #print>>sys.stderr, str(e) + print('Fatal error, could not open logfile "%s"' % cfg.log.file, file=sys.stderr) + sys.exit(1) + else: + logfile = logging.sys.stderr + + + if option.verbose: + level = cfg.log.level + else: + level = logging.ERROR + + logging.basicConfig(level = level, + format='%(asctime)s %(levelname)s %(message)s', + stream = logfile) + + # As the default try to run as daemon. Silently degrade to running as a normal application if this fails + # unless the user explicitly defined what he expected with the -a / -d parameter. + try: + if option.force_app: + raise ImportError # Pretend that we couldn't import the daemon lib + import daemon + except ImportError: + if option.force_daemon: + print('Fatal error, could not daemonize process due to missing "daemon" library, ' \ + 'please install the missing dependency and restart the authenticator', file=sys.stderr) + sys.exit(1) + do_main_program() + else: + context = daemon.DaemonContext(working_directory = sys.path[0], + stderr = logfile) + context.__enter__() + try: + do_main_program() + finally: + context.__exit__(None, None, None) diff --git a/config/services/murmur-ldap/default.nix b/config/services/murmur-ldap/default.nix new file mode 100644 index 00000000..dc3f314a --- /dev/null +++ b/config/services/murmur-ldap/default.nix @@ -0,0 +1,80 @@ +{ config, lib, tf, pkgs, ... }: with lib; let + murmurLdapScript = ./LDAPauth.py; +in { + kw.secrets.variables = { + murmur-ldap-pass = { + path = "social/mumble"; + field = "ldap"; + }; + murmur-ice = { + path = "social/mumble"; + field = "ice"; + }; + }; + + systemd.tmpfiles.rules = [ + "v /etc/murmur 0770 murmur murmur" + ]; + + secrets.files.murmur-ldap-ini = { + text = '' +[user] +id_offset = 1000000000 +reject_on_error = True +reject_on_miss = False + +[ice] +host = 127.0.0.1 +port = 6502 +slice = /etc/murmur/Murmur.ice +secret =${tf.variables.murmur-ice.ref} +watchdog = 30 + +[ldap] +bind_dn = cn=murmur,ou=services,dc=kittywit,dc=ch +bind_pass = ${tf.variables.murmur-ldap-pass.ref} +ldap_uri = ldaps://auth.kittywit.ch:636 +users_dn = ou=users,dc=kittywit,dc=ch +discover_dn = false +username_attr = uid +number_attr = uidNumber +display_attr = cn +provide_info = True +mail_attr = mail +provide_users = True + +[murmur] +servers = + +[log] +level = +file = + +[iceraw] +Ice.ThreadPool.Server.Size = 5 + ''; + owner = "murmur"; + group = "murmur"; + }; + + environment.etc."murmur/LDAPauth.ini".source = config.secrets.files.murmur-ldap-ini.path; + + systemd.services.murmur-ldap = let + pythonEnv = pkgs.python39.withPackages(ps: with ps; [ + ldap + zeroc-ice + python-daemon + ]); + in { + after = [ "network.target" "murmur.service" ]; + path = with pkgs; [ + zeroc-ice + ]; + serviceConfig = { + User = "murmur"; + Group = "murmur"; + ExecStart = "${pythonEnv}/bin/python3 ${murmurLdapScript}"; + WorkingDirectory = "/etc/murmur/"; + }; + }; +} diff --git a/config/services/murmur.nix b/config/services/murmur.nix index f78dddb4..fc3b72a8 100644 --- a/config/services/murmur.nix +++ b/config/services/murmur.nix @@ -19,6 +19,10 @@ in path = "social/mumble"; field = "password"; }; + murmur-ice = { + path = "social/mumble"; + field = "ice"; + }; }; secrets.files.murmur-config = { @@ -61,10 +65,14 @@ in hostName = "voice.${config.network.dns.domain}"; bandwidth = 130000; welcometext = "mew!"; + package = pkgs.murmur.override (old: { iceSupport = true; }); password = tf.variables.murmur-password.ref; extraConfig = '' sslCert=/var/lib/acme/services_murmur/fullchain.pem sslKey=/var/lib/acme/services_murmur/key.pem + ice="tcp -h 127.0.0.1 -p 6502" + icesecretread=${tf.variables.murmur-ice.ref} + icesecretwrite=${tf.variables.murmur-ice.ref} ''; }; diff --git a/config/services/openldap/default.nix b/config/services/openldap/default.nix index 2fa1544c..d5b26aee 100644 --- a/config/services/openldap/default.nix +++ b/config/services/openldap/default.nix @@ -59,21 +59,17 @@ ''{0}to attrs=userPassword by anonymous auth by dn.base="cn=dovecot,dc=mail,dc=kittywit,dc=ch" read - by dn.base="cn=xbackbone,ou=services,dc=kittywit,dc=ch" read - by dn.base="cn=sogo,ou=services,dc=kittywit,dc=ch" read + by dn.subtree="ou=services,dc=kittywit,dc=ch" read by self write by * none'' ''{1}to dn.subtree="dc=kittywit,dc=ch" by dn.exact="cn=root,dc=kittywit,dc=ch" manage - by dn.base="cn=xbackbone,ou=services,dc=kittywit,dc=ch" read by dn.base="cn=dovecot,dc=mail,dc=kittywit,dc=ch" read - by dn.base="cn=sogo,ou=services,dc=kittywit,dc=ch" read + by dn.subtree="ou=services,dc=kittywit,dc=ch" read by dn.subtree="ou=users,dc=kittywit,dc=ch" read '' ''{2}to dn.subtree="ou=users,dc=kittywit,dc=ch" by dn.base="cn=dovecot,dc=mail,dc=kittywit,dc=ch" read - by dn.base="cn=xbackbone,ou=services,dc=kittywit,dc=ch" read - by dn.base="cn=sogo,ou=services,dc=kittywit,dc=ch" read by dn.subtree="ou=users,dc=kittywit,dc=ch" read by dn.subtree="ou=services,dc=kittywit,dc=ch" read by * none''