From e93ad609fa3d5cf8736c3fbb07699ea814f868a0 Mon Sep 17 00:00:00 2001 From: William Brown Date: Mon, 9 Nov 2015 15:21:21 +1000 Subject: [PATCH] Ticket 48343 - lib389 krb5 realm management https://fedorahosted.org/389/ticket/48343 Bug Description: We need to be able to test gssapi and other functions for 389. Historically we used external krb5 services, but that led to potential issues with keytab state and reliability. This will allow us to create and destroy basic krb5 realms for testing purposes. Fix Description: We can use this in the following way: from lib389.mit_krb5 import MitKrb5 krb = MitKrb5(realm="EXAMPLE.COM") krb.create_realm() krb.create_principal(principal="ldap/localhost.localdomain") krb.create_keytab(principal="ldap/localhost.localdomain", keytab="/etc/dirsrv/slapd/ldap.keytab") krb.destroy_realm() While creating a DirSrv object, provided you have the REALM correctly configured we automatically extract the keytab for the instance. Author: wibrown Review by: spichugi --- clitools/krb_create_keytab.py | 25 +++++ clitools/krb_create_principal.py | 24 +++++ clitools/krb_create_realm.py | 23 +++++ clitools/krb_destroy_realm.py | 23 +++++ lib389/__init__.py | 33 +++++- lib389/_constants.py | 1 + lib389/mit_krb5.py | 217 +++++++++++++++++++++++++++++++++++++++ lib389/properties.py | 3 +- lib389/tools.py | 58 +++++++---- lib389/utils.py | 4 +- tests/krb5_create_test.py | 93 +++++++++++++++++ 11 files changed, 477 insertions(+), 27 deletions(-) create mode 100644 clitools/krb_create_keytab.py create mode 100644 clitools/krb_create_principal.py create mode 100644 clitools/krb_create_realm.py create mode 100644 clitools/krb_destroy_realm.py create mode 100644 lib389/mit_krb5.py create mode 100644 tests/krb5_create_test.py diff --git a/clitools/krb_create_keytab.py b/clitools/krb_create_keytab.py new file mode 100644 index 0000000..f3238e0 --- /dev/null +++ b/clitools/krb_create_keytab.py @@ -0,0 +1,25 @@ +#!/usr/bin/python + +from clitools import CliTool, clitools_parser +from lib389._constants import * +from lib389.mit_krb5 import MitKrb5 +from argparse import ArgumentParser + +class MitKrb5Tool(CliTool): + def mit_krb5_realm_create(self): + try: + krb = MitKrb5(realm=args.realm, warnings=True) + krb.create_keytab(principal=args.principal, keytab=args.keytab) + finally: + pass + +if __name__ == '__main__': + # Do some arg parse stuff + ## You can always add a child parser here too ... + parser = ArgumentParser(parents=[clitools_parser]) + parser.add_argument('--realm', '-r', help='The name of the realm to create', required=True) + parser.add_argument('--principal', '-p', help='The name of the principal to create', required=True) + parser.add_argument('--keytab', '-k', help='The path to the keytab to create', required=True) + args = parser.parse_args() + mittool = MitKrb5Tool(args) + mittool.mit_krb5_realm_create() diff --git a/clitools/krb_create_principal.py b/clitools/krb_create_principal.py new file mode 100644 index 0000000..5d14715 --- /dev/null +++ b/clitools/krb_create_principal.py @@ -0,0 +1,24 @@ +#!/usr/bin/python + +from clitools import CliTool, clitools_parser +from lib389._constants import * +from lib389.mit_krb5 import MitKrb5 +from argparse import ArgumentParser + +class MitKrb5Tool(CliTool): + def mit_krb5_realm_create(self): + try: + krb = MitKrb5(realm=args.realm, warnings=True) + krb.create_principal(principal=args.principal) + finally: + pass + +if __name__ == '__main__': + # Do some arg parse stuff + ## You can always add a child parser here too ... + parser = ArgumentParser(parents=[clitools_parser]) + parser.add_argument('--realm', '-r', help='The name of the realm to create', required=True) + parser.add_argument('--principal', '-p', help='The name of the principal to create', required=True) + args = parser.parse_args() + mittool = MitKrb5Tool(args) + mittool.mit_krb5_realm_create() diff --git a/clitools/krb_create_realm.py b/clitools/krb_create_realm.py new file mode 100644 index 0000000..acf3e9c --- /dev/null +++ b/clitools/krb_create_realm.py @@ -0,0 +1,23 @@ +#!/usr/bin/python + +from clitools import CliTool, clitools_parser +from lib389._constants import * +from lib389.mit_krb5 import MitKrb5 +from argparse import ArgumentParser + +class MitKrb5Tool(CliTool): + def mit_krb5_realm_create(self): + try: + krb = MitKrb5(realm=args.realm, warnings=True) + krb.create_realm() + finally: + pass + +if __name__ == '__main__': + # Do some arg parse stuff + ## You can always add a child parser here too ... + parser = ArgumentParser(parents=[clitools_parser]) + parser.add_argument('--realm', '-r', help='The name of the realm to create', required=True) + args = parser.parse_args() + mittool = MitKrb5Tool(args) + mittool.mit_krb5_realm_create() diff --git a/clitools/krb_destroy_realm.py b/clitools/krb_destroy_realm.py new file mode 100644 index 0000000..2237aca --- /dev/null +++ b/clitools/krb_destroy_realm.py @@ -0,0 +1,23 @@ +#!/usr/bin/python + +from clitools import CliTool, clitools_parser +from lib389._constants import * +from lib389.mit_krb5 import MitKrb5 +from argparse import ArgumentParser + +class MitKrb5Tool(CliTool): + def mit_krb5_realm_destroy(self): + try: + krb = MitKrb5(realm=args.realm, warnings=True) + krb.destroy_realm() + finally: + pass + +if __name__ == '__main__': + # Do some arg parse stuff + ## You can always add a child parser here too ... + parser = ArgumentParser(parents=[clitools_parser]) + parser.add_argument('--realm', '-r', help='The name of the realm to DESTROY', required=True) + args = parser.parse_args() + mittool = MitKrb5Tool(args) + mittool.mit_krb5_realm_destroy() diff --git a/lib389/__init__.py b/lib389/__init__.py index 27db583..621ae83 100644 --- a/lib389/__init__.py +++ b/lib389/__init__.py @@ -34,10 +34,15 @@ import glob import tarfile import subprocess import collections -import six.moves.urllib.request -import six.moves.urllib.parse -import six.moves.urllib.error -import six +try: + # There are too many issues with this on EL7 + # Out of the box, it's just outright broken ... + import six.moves.urllib.request + import six.moves.urllib.parse + import six.moves.urllib.error + import six +except ImportError: + pass from ldap.ldapobject import SimpleLDAPObject # file in this package @@ -46,6 +51,7 @@ from lib389.properties import * from lib389._entry import Entry from lib389._ldifconn import LDIFConn from lib389.tools import DirSrvTools +from lib389.mit_krb5 import MitKrb5 from lib389.utils import ( isLocalHost, is_a_dn, @@ -395,6 +401,7 @@ class DirSrv(SimpleLDAPObject): args_instance[SER_CREATION_SUFFIX] = DEFAULT_SUFFIX args_instance[SER_USER_ID] = None args_instance[SER_GROUP_ID] = None + args_instance[SER_REALM] = None # We allocate a "default" prefix here which allows an un-allocate or # un-instantiated DirSrv @@ -465,6 +472,9 @@ class DirSrv(SimpleLDAPObject): # Allocate from the args, or use our env, or use / if args.get(SER_DEPLOYED_DIR, self.prefix) is not None: self.prefix = args.get(SER_DEPLOYED_DIR, self.prefix) + self.realm = args.get(SER_REALM, None) + if self.realm is not None: + self.krb5_realm = MitKrb5(realm=self.realm, debug=self.verbose) # Those variables needs to be revisited (sroot for 64 bits) # self.sroot = os.path.join(self.prefix, "lib/dirsrv") @@ -766,6 +776,7 @@ class DirSrv(SimpleLDAPObject): SER_GROUP_ID (groupid) SER_DEPLOYED_DIR (prefix) SER_BACKUP_INST_DIR (backupdir) + SER_REALM (krb5_realm) @return None @@ -781,6 +792,7 @@ class DirSrv(SimpleLDAPObject): log.error("Can't find file: %r, removing extension" % prog) prog = prog[:-3] + # Create and extract a service keytab args = {SER_HOST: self.host, SER_PORT: self.port, SER_SECURE_PORT: self.sslport, @@ -797,6 +809,17 @@ class DirSrv(SimpleLDAPObject): prefix=self.prefix) if result != 0: raise Exception('Failed to run setup-ds.pl') + if self.realm is not None: + # This may conflict in some tests, we may need to use /etc/host aliases + # or we may need to use server id + self.krb5_realm.create_principal(principal='ldap/%s' % self.host) + self.krb5_realm.create_keytab(principal='ldap/%s' % self.host, + keytab='%s/etc/dirsrv/slapd-%s/ldap.keytab' % (self.prefix, self.serverid)) + with open('%s/etc/sysconfig/dirsrv-%s' % (self.prefix, self.serverid), 'a') as sfile: + sfile.write("\nKRB5_KTNAME=%s/etc/dirsrv/slapd-%s/ldap.keytab\nexport KRB5_KTNAME\n" % (self.prefix, self.serverid)) + self.restart() + + # Restart the instance def create(self): """ @@ -1031,7 +1054,7 @@ class DirSrv(SimpleLDAPObject): # whatever the initial state, the instance is now Offline self.state = DIRSRV_STATE_OFFLINE - def restart(self, timeout): + def restart(self, timeout=120): ''' It restarts an instance and rebind it. Its final state after rebind (open) is DIRSRV_STATE_ONLINE. diff --git a/lib389/_constants.py b/lib389/_constants.py index 79e8da6..005ba7e 100644 --- a/lib389/_constants.py +++ b/lib389/_constants.py @@ -437,6 +437,7 @@ args_instance = { SER_HOST: LOCALHOST, SER_PORT: DEFAULT_PORT, SER_SERVERID_PROP: "template", + SER_REALM: None, SER_CREATION_SUFFIX: DEFAULT_SUFFIX} # Helper for linking dse.ldif values to the parse_config function diff --git a/lib389/mit_krb5.py b/lib389/mit_krb5.py new file mode 100644 index 0000000..cd2275c --- /dev/null +++ b/lib389/mit_krb5.py @@ -0,0 +1,217 @@ +"""Mit_krb5 kdc setup routine. + +Not ideal for use for a PRD krb system, used internally for testing GSSAPI +integration with 389ds. + +""" +## In the future we might add support for an ldap-backed krb realm +from subprocess import Popen, PIPE +import krbV +import os +import signal + +from lib389._constants import * +from socket import getfqdn +from lib389.utils import getdomainname +from lib389.tools import DirSrvTools + +class MitKrb5(object): + # Get the realm information + def __init__(self, realm, warnings=False, debug=False): + self.warnings = warnings + self.realm = realm.upper() + # For the future if we have a non-os krb install. + self.krb_prefix = "" + # Probably should be using os.path.join ... + self.kadmin = "%s/usr/sbin/kadmin.local" % self.krb_prefix + self.kdb5_util = "%s/usr/sbin/kdb5_util" % self.krb_prefix + self.krb5kdc = "%s/usr/sbin/krb5kdc" % self.krb_prefix + self.kdcconf = "%s/var/kerberos/krb5kdc/kdc.conf" % self.krb_prefix + self.kdcpid = "%s/var/run/krb5kdc.pid" % self.krb_prefix + self.krb5conf = "%s/etc/krb5.conf" % (self.krb_prefix ) + self.krb5confrealm = "%s/etc/krb5.conf.d/%s" % (self.krb_prefix, self.realm.lower().replace('.', '-') ) + + # THIS IS NOT SECURE + # We should probably randomise this per install + # We should write it to a file too ... + self.krb_master_password = 'si7athohyiezah9riz6Aayaiphoo1ii0uashail5' + + self.krb_env = {} + if debug is True: + self.krb_env['KRB5_TRACE'] = '/tmp/krb_lib389.trace' + + + # Validate our hostname is in /etc/hosts, and is fqdn + def validate_hostname(self): + if getdomainname() == "": + # Should we return a message? + raise AssertionError("Host does not have a domain name.") + DirSrvTools.searchHostsFile(getfqdn()) + + # Check if a realm exists or not. + # Should we check globally? Or just on this local host. + def check_realm(self): + p = Popen([self.kadmin, '-r', self.realm, '-q', 'list_principals'], env=self.krb_env, stdout=PIPE, stderr=PIPE) + returncode = p.wait() + if returncode == 0: + return True + return False + + # Be able to setup the real + def create_realm(self, ignore=False): + self.validate_hostname() + exists = self.check_realm() + if exists is True and ignore is False: + raise AssertionError("Realm already exists!") + elif exists is True and ignore is True: + # Realm exists, continue + return + # Raise a scary warning about eating your krb settings + if self.warnings: + print("This will alter / erase your krb5 and kdc settings.") + print("THIS IS NOT A SECURE KRB5 INSTALL, DO NOT USE IN PRODUCTION") + raw_input("Ctrl-C to exit, or press ENTER to continue.") + + # If we don't have the directories for this, create them. + # but if we create them there is no guarantee this will work ... + if not os.path.exists(os.path.dirname(self.krb5confrealm)): + os.makedirs(os.path.dirname(self.krb5confrealm)) + # Put the includedir statement into /etc/krb5.conf + include = True + with open(self.krb5conf, 'r') as kfile: + for line in kfile.readlines(): + if 'includedir %s/' % os.path.dirname(self.krb5confrealm) in line: + include = False + if include is True: + with open(self.krb5conf, 'a') as kfile: + kfile.write('\nincludedir %s/\n' % os.path.dirname(self.krb5confrealm)) + + # Write to /etc/krb5.conf.d/example.com + with open(self.krb5confrealm, 'w') as cfile: + cfile.write(""" +[realms] +{REALM} = {{ + kdc = {HOST} + admin_server = {HOST} +}} + +[domain_realm] +.{LREALM} = {REALM} +{LREALM} = {REALM} +.{DOMAIN} = {REALM} +{DOMAIN} = {REALM} +""".format( + HOST=getfqdn(), + REALM=self.realm, + LREALM=self.realm.lower(), + DOMAIN=getdomainname(), + )) + # Do we need to edit /var/kerberos/krb5kdc/kdc.conf ? + with open(self.kdcconf, 'w') as kfile: + kfile.write(""" +[kdcdefaults] + kdc_ports = 88 + kdc_tcp_ports = 88 + +[realms] + {REALM} = {{ + #master_key_type = aes256-cts + acl_file = {PREFIX}/var/kerberos/krb5kdc/kadm5.acl + dict_file = /usr/share/dict/words + admin_keytab = {PREFIX}/var/kerberos/krb5kdc/kadm5.keytab + # Just use strong enctypes + supported_enctypes = aes256-cts:normal aes128-cts:normal + }} + +""".format( + REALM=self.realm, + PREFIX=self.krb_prefix, + )) + # Invoke kdb5_util + # Can this use -P + p = Popen([self.kdb5_util, 'create', '-r', self.realm, '-s', '-P', self.krb_master_password] , env=self.krb_env) + assert(p.wait() == 0) + # Start the kdc + p = Popen([self.krb5kdc, '-P', self.kdcpid, '-r', self.realm], env=self.krb_env) + assert(p.wait() == 0) + # ??? + # PROFIT + return + + # Destroy the realm + def destroy_realm(self): + assert(self.check_realm()) + if self.warnings: + print("This will ERASE your kdc settings.") + raw_input("Ctrl-C to exit, or press ENTER to continue.") + # If the pid exissts, try to kill it. + if os.path.isfile(self.kdcpid): + with open(self.kdcpid, 'r') as pfile: + pid = int(pfile.readline().strip()) + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + os.remove(self.kdcpid) + + p = Popen([self.kdb5_util, 'destroy', '-r', self.realm] , env=self.krb_env, stdin=PIPE) + p.communicate("yes\n") + p.wait() + assert(p.returncode == 0) + # Should we clean up the configurations we made too? + # ??? + # PROFIT + return + + def list_principals(self): + assert(self.check_realm()) + p = Popen([self.kadmin, '-r', self.realm, '-q', 'list_principals'], env=self.krb_env) + p.wait() + + # Create princs for services + def create_principal(self, principal, password=None): + assert(self.check_realm()) + p = None + if password: + p = Popen([self.kadmin, '-r', self.realm, '-q', 'add_principal -pw \'%s\' %s@%s' % (password, principal, self.realm)], env=self.krb_env) + else: + p = Popen([self.kadmin, '-r', self.realm, '-q', 'add_principal -randkey %s@%s' % (principal, self.realm)], env=self.krb_env) + assert(p.wait() == 0) + self.list_principals() + + # Extract KTs for services + def create_keytab(self, principal, keytab): + assert(self.check_realm()) + # Remove the old keytab + try: + os.remove(keytab) + except: + pass + p = Popen([self.kadmin, '-r', self.realm, '-q', 'ktadd -k %s %s@%s' % (keytab, principal, self.realm)]) + assert(p.wait() == 0) + + +class KrbClient(object): + def __init__(self, principal, keytab, cache_file=None): + self.context = krbV.default_context() + self.principal = principal + self.keytab = keytab + self._keytab = krbV.Keytab(name=self.keytab, context=self.context) + self._principal = krbV.Principal(name=self.principal, context=self.context) + if cache_file: + self.ccache = krbV.CCache(name="FILE:"+cache_file, context=self.context, + primary_principal=self._principal) + else: + self.ccache = self.context.default_ccache(primary_principal=self._principal) + if self._keytab: + self.reinit() + + def reinit(self): + assert self._keytab + assert self._principal + self.ccache.init(self._principal) + self.ccache.init_creds_keytab(keytab=self._keytab, principal=self._principal) + + + + diff --git a/lib389/properties.py b/lib389/properties.py index bc0da7e..022608b 100644 --- a/lib389/properties.py +++ b/lib389/properties.py @@ -34,6 +34,7 @@ SER_GROUP_ID ='group-id' SER_DEPLOYED_DIR ='deployed-dir' SER_BACKUP_INST_DIR ='inst-backupdir' SER_CREATION_SUFFIX ='suffix' +SER_REALM = 'krb5_realm' #################################### # @@ -292,4 +293,4 @@ def inProperties(prop, properties): if rawProperty(prop) in properties: return True else: - return False \ No newline at end of file + return False diff --git a/lib389/tools.py b/lib389/tools.py index 6f5da08..f1b8c88 100644 --- a/lib389/tools.py +++ b/lib389/tools.py @@ -25,9 +25,15 @@ import glob import pwd import grp import logging -import six.moves.urllib.request -import six.moves.urllib.parse -import six.moves.urllib.error +try: + # There are too many issues with this on EL7 + # Out of the box, it's just outright broken ... + import six.moves.urllib.request + import six.moves.urllib.parse + import six.moves.urllib.error + import six +except ImportError: + pass import ldap from lib389._constants import * @@ -870,30 +876,42 @@ class DirSrvTools(object): DirSrvTools.makeUser(user=user, group=user, home=DEFAULT_USERHOME) @staticmethod + def searchHostsFile(expectedHost, ipPattern=None): + hostFile = '/etc/hosts' + + with open(hostFile, 'r') as hostfp: + # The with statement will automatically close the file after use + + # We are already at the start of the file + # hostfp.seek(0, os.SEEK_CUR) + + try: + for line in hostfp.readlines(): + if ipPattern is None: + words = line.split() + assert(words[1] == expectedHost) + return True + else: + if line.find(ipPattern) >= 0: + words = line.split() + # We just want to make sure it's in there somewhere. + assert(expectedHost in words) + return True + except AssertionError: + raise AssertionError("Error: /etc/hosts should contain '%s' as first host for %s" % + (expectedHost ,ipPattern)) + raise AssertionError("Error: /etc/hosts does not contain '%s' as first host for %s" % + (expectedHost ,ipPattern)) + + @staticmethod def testLocalhost(): ''' Checks that the 127.0.0.1 is resolved as localhost.localdomain This is required by DSUtil.pm:checkHostname else setup-ds.pl fails ''' - hostFile = '/etc/hosts' loopbackIpPattern = '127.0.0.1' expectedHost = 'localhost.localdomain' - - hostfp = open(hostFile, 'r') - hostfp.seek(0, os.SEEK_CUR) - - done = False - try: - while not done: - line = hostfp.readline() - if line.find(loopbackIpPattern) >= 0: - words = line.split() - # We just want to make sure it's in there somewhere. - assert(expectedHost in words) - done = True - except AssertionError: - raise AssertionError("Error: /etc/hosts should contains 'localhost.localdomain' as first host for %s" % - (loopbackIpPattern)) + DirSrvTools.searchHostsFile(expectedHost, loopbackIpPattern) @staticmethod def runUpgrade(prefix, online=True): diff --git a/lib389/utils.py b/lib389/utils.py index eab9826..501fbcf 100644 --- a/lib389/utils.py +++ b/lib389/utils.py @@ -353,7 +353,9 @@ def getdomainname(name=''): if index >= 0: return fqdn[index + 1:] else: - return fqdn + # This isn't correct. There is no domain name, so return empty.str + # return fqdn + return "" def getdefaultsuffix(name=''): diff --git a/tests/krb5_create_test.py b/tests/krb5_create_test.py new file mode 100644 index 0000000..434a353 --- /dev/null +++ b/tests/krb5_create_test.py @@ -0,0 +1,93 @@ + +# --- BEGIN COPYRIGHT BLOCK --- +# Copyright (C) 2015 Red Hat, Inc. +# All rights reserved. +# +# License: GPL (version 3 or any later version). +# See LICENSE for details. +# --- END COPYRIGHT BLOCK --- +# +from lib389._constants import * +from lib389.mit_krb5 import MitKrb5, KrbClient +from lib389 import DirSrv,Entry +import pytest +import logging + +import ldap +import ldap.sasl + +logging.getLogger(__name__).setLevel(logging.DEBUG) +log = logging.getLogger(__name__) + +INSTANCE_PORT = 54321 +INSTANCE_SERVERID = 'gssapi' +REALM = "EXAMPLE.COM" +TEST_USER = 'uid=test,%s' % DEFAULT_SUFFIX + +class TopologyInstance(object): + def __init__(self, instance): + instance.open() + self.instance = instance + + +@pytest.fixture(scope="module") +def topology(request): + # Create the realm + krb = MitKrb5(realm=REALM) + instance = DirSrv(verbose=False) + instance.log.debug("Instance allocated") + args = {SER_HOST: LOCALHOST, + SER_PORT: INSTANCE_PORT, + SER_REALM: REALM, + SER_SERVERID_PROP: INSTANCE_SERVERID} + instance.allocate(args) + if instance.exists(): + instance.delete() + # Its likely our realm exists too + if krb.check_realm(): + krb.destroy_realm() + # This will automatically create the krb entries + krb.create_realm() + instance.create() + instance.open() + + def fin(): + if instance.exists(): + instance.delete() + if krb.check_realm(): + krb.destroy_realm() + request.addfinalizer(fin) + + return TopologyInstance(instance) + +@pytest.fixture(scope="module") +def add_user(topology): + """Create a user entry""" + + log.info('Create a user entry: %s' % TEST_USER) + uentry = Entry(TEST_USER) + uentry.setValues('objectclass', 'top', 'extensibleobject') + uentry.setValues('uid', 'test') + topology.instance.add_s(uentry) + # This doesn't matter that we re-open the realm + krb = MitKrb5(realm=REALM) + krb.create_principal("test") + # We extract the kt so we can kinit from it + krb.create_keytab("test", "/tmp/test.keytab") + +def test_gssapi(topology, add_user): + """Check that our bind completese with ldapwhoami correctly mapped from +the principal to our test user object. + """ + # Init our local ccache + kclient = KrbClient("test@%s" % REALM, "/tmp/test.keytab") + # Probably need to change this to NOT be raw python ldap + conn = ldap.initialize("ldap://%s:%s" % (LOCALHOST, INSTANCE_PORT)) + sasl = ldap.sasl.gssapi() + conn.sasl_interactive_bind_s('', sasl) + assert(conn.whoami_s() == "dn: uid=test,dc=example,dc=com") + + +if __name__ == "__main__": + CURRENT_FILE = os.path.realpath(__file__) + pytest.main("-s -v %s" % CURRENT_FILE) -- 2.5.0