Development of an internal social media platform with personalised dashboards for students
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

_slapdtest.py 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. # -*- coding: utf-8 -*-
  2. """
  3. slapdtest - module for spawning test instances of OpenLDAP's slapd server
  4. See https://www.python-ldap.org/ for details.
  5. """
  6. from __future__ import unicode_literals
  7. import os
  8. import socket
  9. import sys
  10. import time
  11. import subprocess
  12. import logging
  13. import atexit
  14. from logging.handlers import SysLogHandler
  15. import unittest
  16. # Switch off processing .ldaprc or ldap.conf before importing _ldap
  17. os.environ['LDAPNOINIT'] = '1'
  18. import ldap
  19. from ldap.compat import quote_plus, which
  20. HERE = os.path.abspath(os.path.dirname(__file__))
  21. # a template string for generating simple slapd.conf file
  22. SLAPD_CONF_TEMPLATE = r"""
  23. serverID %(serverid)s
  24. moduleload back_%(database)s
  25. %(include_directives)s
  26. loglevel %(loglevel)s
  27. allow bind_v2
  28. authz-regexp
  29. "gidnumber=%(root_gid)s\\+uidnumber=%(root_uid)s,cn=peercred,cn=external,cn=auth"
  30. "%(rootdn)s"
  31. database %(database)s
  32. directory "%(directory)s"
  33. suffix "%(suffix)s"
  34. rootdn "%(rootdn)s"
  35. rootpw "%(rootpw)s"
  36. TLSCACertificateFile "%(cafile)s"
  37. TLSCertificateFile "%(servercert)s"
  38. TLSCertificateKeyFile "%(serverkey)s"
  39. # ignore missing client cert but fail with invalid client cert
  40. TLSVerifyClient try
  41. authz-regexp
  42. "C=DE, O=python-ldap, OU=slapd-test, CN=([A-Za-z]+)"
  43. "ldap://ou=people,dc=local???($1)"
  44. """
  45. LOCALHOST = '127.0.0.1'
  46. CI_DISABLED = set(os.environ.get('CI_DISABLED', '').split(':'))
  47. if 'LDAPI' in CI_DISABLED:
  48. HAVE_LDAPI = False
  49. else:
  50. HAVE_LDAPI = hasattr(socket, 'AF_UNIX')
  51. def identity(test_item):
  52. """Identity decorator
  53. """
  54. return test_item
  55. def skip_unless_ci(reason, feature=None):
  56. """Skip test unless test case is executed on CI like Travis CI
  57. """
  58. if not os.environ.get('CI', False):
  59. return unittest.skip(reason)
  60. elif feature in CI_DISABLED:
  61. return unittest.skip(reason)
  62. else:
  63. # Don't skip on Travis
  64. return identity
  65. def requires_tls():
  66. """Decorator for TLS tests
  67. Tests are not skipped on CI (e.g. Travis CI)
  68. """
  69. if not ldap.TLS_AVAIL:
  70. return skip_unless_ci("test needs ldap.TLS_AVAIL", feature='TLS')
  71. else:
  72. return identity
  73. def requires_sasl():
  74. if not ldap.SASL_AVAIL:
  75. return skip_unless_ci(
  76. "test needs ldap.SASL_AVAIL", feature='SASL')
  77. else:
  78. return identity
  79. def requires_ldapi():
  80. if not HAVE_LDAPI:
  81. return skip_unless_ci(
  82. "test needs ldapi support (AF_UNIX)", feature='LDAPI')
  83. else:
  84. return identity
  85. def _add_sbin(path):
  86. """Add /sbin and related directories to a command search path"""
  87. directories = path.split(os.pathsep)
  88. if sys.platform != 'win32':
  89. for sbin in '/usr/local/sbin', '/sbin', '/usr/sbin':
  90. if sbin not in directories:
  91. directories.append(sbin)
  92. return os.pathsep.join(directories)
  93. def combined_logger(
  94. log_name,
  95. log_level=logging.WARN,
  96. sys_log_format='%(levelname)s %(message)s',
  97. console_log_format='%(asctime)s %(levelname)s %(message)s',
  98. ):
  99. """
  100. Returns a combined SysLogHandler/StreamHandler logging instance
  101. with formatters
  102. """
  103. if 'LOGLEVEL' in os.environ:
  104. log_level = os.environ['LOGLEVEL']
  105. try:
  106. log_level = int(log_level)
  107. except ValueError:
  108. pass
  109. # for writing to syslog
  110. new_logger = logging.getLogger(log_name)
  111. if sys_log_format and os.path.exists('/dev/log'):
  112. my_syslog_formatter = logging.Formatter(
  113. fmt=' '.join((log_name, sys_log_format)))
  114. my_syslog_handler = logging.handlers.SysLogHandler(
  115. address='/dev/log',
  116. facility=SysLogHandler.LOG_DAEMON,
  117. )
  118. my_syslog_handler.setFormatter(my_syslog_formatter)
  119. new_logger.addHandler(my_syslog_handler)
  120. if console_log_format:
  121. my_stream_formatter = logging.Formatter(fmt=console_log_format)
  122. my_stream_handler = logging.StreamHandler()
  123. my_stream_handler.setFormatter(my_stream_formatter)
  124. new_logger.addHandler(my_stream_handler)
  125. new_logger.setLevel(log_level)
  126. return new_logger # end of combined_logger()
  127. class SlapdObject(object):
  128. """
  129. Controller class for a slapd instance, OpenLDAP's server.
  130. This class creates a temporary data store for slapd, runs it
  131. listening on a private Unix domain socket and TCP port,
  132. and initializes it with a top-level entry and the root user.
  133. When a reference to an instance of this class is lost, the slapd
  134. server is shut down.
  135. An instance can be used as a context manager. When exiting the context
  136. manager, the slapd server is shut down and the temporary data store is
  137. removed.
  138. .. versionchanged:: 3.1
  139. Added context manager functionality
  140. """
  141. slapd_conf_template = SLAPD_CONF_TEMPLATE
  142. database = 'mdb'
  143. suffix = 'dc=slapd-test,dc=python-ldap,dc=org'
  144. root_cn = 'Manager'
  145. root_pw = 'password'
  146. slapd_loglevel = 'stats stats2'
  147. local_host = '127.0.0.1'
  148. testrunsubdirs = (
  149. 'schema',
  150. )
  151. openldap_schema_files = (
  152. 'core.schema',
  153. )
  154. TMPDIR = os.environ.get('TMP', os.getcwd())
  155. if 'SCHEMA' in os.environ:
  156. SCHEMADIR = os.environ['SCHEMA']
  157. elif os.path.isdir("/etc/openldap/schema"):
  158. SCHEMADIR = "/etc/openldap/schema"
  159. elif os.path.isdir("/etc/ldap/schema"):
  160. SCHEMADIR = "/etc/ldap/schema"
  161. else:
  162. SCHEMADIR = None
  163. BIN_PATH = os.environ.get('BIN', os.environ.get('PATH', os.defpath))
  164. SBIN_PATH = os.environ.get('SBIN', _add_sbin(BIN_PATH))
  165. # time in secs to wait before trying to access slapd via LDAP (again)
  166. _start_sleep = 1.5
  167. # create loggers once, multiple calls mess up refleak tests
  168. _log = combined_logger('python-ldap-test')
  169. def __init__(self):
  170. self._proc = None
  171. self._port = self._avail_tcp_port()
  172. self.server_id = self._port % 4096
  173. self.testrundir = os.path.join(self.TMPDIR, 'python-ldap-test-%d' % self._port)
  174. self._schema_prefix = os.path.join(self.testrundir, 'schema')
  175. self._slapd_conf = os.path.join(self.testrundir, 'slapd.conf')
  176. self._db_directory = os.path.join(self.testrundir, "openldap-data")
  177. self.ldap_uri = "ldap://%s:%d/" % (LOCALHOST, self._port)
  178. if HAVE_LDAPI:
  179. ldapi_path = os.path.join(self.testrundir, 'ldapi')
  180. self.ldapi_uri = "ldapi://%s" % quote_plus(ldapi_path)
  181. self.default_ldap_uri = self.ldapi_uri
  182. # use SASL/EXTERNAL via LDAPI when invoking OpenLDAP CLI tools
  183. self.cli_sasl_external = ldap.SASL_AVAIL
  184. else:
  185. self.ldapi_uri = None
  186. self.default_ldap_uri = self.ldap_uri
  187. # Use simple bind via LDAP uri
  188. self.cli_sasl_external = False
  189. self._find_commands()
  190. if self.SCHEMADIR is None:
  191. raise ValueError('SCHEMADIR is None, ldap schemas are missing.')
  192. # TLS certs
  193. self.cafile = os.path.join(HERE, 'certs/ca.pem')
  194. self.servercert = os.path.join(HERE, 'certs/server.pem')
  195. self.serverkey = os.path.join(HERE, 'certs/server.key')
  196. self.clientcert = os.path.join(HERE, 'certs/client.pem')
  197. self.clientkey = os.path.join(HERE, 'certs/client.key')
  198. @property
  199. def root_dn(self):
  200. return 'cn={self.root_cn},{self.suffix}'.format(self=self)
  201. def _find_commands(self):
  202. self.PATH_LDAPADD = self._find_command('ldapadd')
  203. self.PATH_LDAPDELETE = self._find_command('ldapdelete')
  204. self.PATH_LDAPMODIFY = self._find_command('ldapmodify')
  205. self.PATH_LDAPWHOAMI = self._find_command('ldapwhoami')
  206. self.PATH_SLAPD = os.environ.get('SLAPD', None)
  207. if not self.PATH_SLAPD:
  208. self.PATH_SLAPD = self._find_command('slapd', in_sbin=True)
  209. self.PATH_SLAPTEST = self._find_command('slaptest', in_sbin=True)
  210. def _find_command(self, cmd, in_sbin=False):
  211. if in_sbin:
  212. path = self.SBIN_PATH
  213. var_name = 'SBIN'
  214. else:
  215. path = self.BIN_PATH
  216. var_name = 'BIN'
  217. command = which(cmd, path=path)
  218. if command is None:
  219. raise ValueError(
  220. "Command '{}' not found. Set the {} environment variable to "
  221. "override slapdtest's search path.".format(cmd, var_name)
  222. )
  223. return command
  224. def setup_rundir(self):
  225. """
  226. creates rundir structure
  227. for setting up a custom directory structure you have to override
  228. this method
  229. """
  230. os.mkdir(self.testrundir)
  231. os.mkdir(self._db_directory)
  232. self._create_sub_dirs(self.testrunsubdirs)
  233. self._ln_schema_files(self.openldap_schema_files, self.SCHEMADIR)
  234. def _cleanup_rundir(self):
  235. """
  236. Recursively delete whole directory specified by `path'
  237. """
  238. # cleanup_rundir() is called in atexit handler. Until Python 3.4,
  239. # the rest of the world is already destroyed.
  240. import os, os.path
  241. if not os.path.exists(self.testrundir):
  242. return
  243. self._log.debug('clean-up %s', self.testrundir)
  244. for dirpath, dirnames, filenames in os.walk(
  245. self.testrundir,
  246. topdown=False
  247. ):
  248. for filename in filenames:
  249. self._log.debug('remove %s', os.path.join(dirpath, filename))
  250. os.remove(os.path.join(dirpath, filename))
  251. for dirname in dirnames:
  252. self._log.debug('rmdir %s', os.path.join(dirpath, dirname))
  253. os.rmdir(os.path.join(dirpath, dirname))
  254. os.rmdir(self.testrundir)
  255. self._log.info('cleaned-up %s', self.testrundir)
  256. def _avail_tcp_port(self):
  257. """
  258. find an available port for TCP connection
  259. """
  260. sock = socket.socket()
  261. try:
  262. sock.bind((self.local_host, 0))
  263. port = sock.getsockname()[1]
  264. finally:
  265. sock.close()
  266. self._log.info('Found available port %d', port)
  267. return port
  268. def gen_config(self):
  269. """
  270. generates a slapd.conf and returns it as one string
  271. for generating specific static configuration files you have to
  272. override this method
  273. """
  274. include_directives = '\n'.join(
  275. 'include "{schema_prefix}/{schema_file}"'.format(
  276. schema_prefix=self._schema_prefix,
  277. schema_file=schema_file,
  278. )
  279. for schema_file in self.openldap_schema_files
  280. )
  281. config_dict = {
  282. 'serverid': hex(self.server_id),
  283. 'schema_prefix':self._schema_prefix,
  284. 'include_directives': include_directives,
  285. 'loglevel': self.slapd_loglevel,
  286. 'database': self.database,
  287. 'directory': self._db_directory,
  288. 'suffix': self.suffix,
  289. 'rootdn': self.root_dn,
  290. 'rootpw': self.root_pw,
  291. 'root_uid': os.getuid(),
  292. 'root_gid': os.getgid(),
  293. 'cafile': self.cafile,
  294. 'servercert': self.servercert,
  295. 'serverkey': self.serverkey,
  296. }
  297. return self.slapd_conf_template % config_dict
  298. def _create_sub_dirs(self, dir_names):
  299. """
  300. create sub-directories beneath self.testrundir
  301. """
  302. for dname in dir_names:
  303. dir_name = os.path.join(self.testrundir, dname)
  304. self._log.debug('Create directory %s', dir_name)
  305. os.mkdir(dir_name)
  306. def _ln_schema_files(self, file_names, source_dir):
  307. """
  308. write symbolic links to original schema files
  309. """
  310. for fname in file_names:
  311. ln_source = os.path.join(source_dir, fname)
  312. ln_target = os.path.join(self._schema_prefix, fname)
  313. self._log.debug('Create symlink %s -> %s', ln_source, ln_target)
  314. os.symlink(ln_source, ln_target)
  315. def _write_config(self):
  316. """Writes the slapd.conf file out, and returns the path to it."""
  317. self._log.debug('Writing config to %s', self._slapd_conf)
  318. with open(self._slapd_conf, 'w') as config_file:
  319. config_file.write(self.gen_config())
  320. self._log.info('Wrote config to %s', self._slapd_conf)
  321. def _test_config(self):
  322. self._log.debug('testing config %s', self._slapd_conf)
  323. popen_list = [
  324. self.PATH_SLAPTEST,
  325. "-f", self._slapd_conf,
  326. '-u',
  327. ]
  328. if self._log.isEnabledFor(logging.DEBUG):
  329. popen_list.append('-v')
  330. popen_list.extend(['-d', 'config'])
  331. else:
  332. popen_list.append('-Q')
  333. proc = subprocess.Popen(popen_list)
  334. if proc.wait() != 0:
  335. raise RuntimeError("configuration test failed")
  336. self._log.info("config ok: %s", self._slapd_conf)
  337. def _start_slapd(self):
  338. """
  339. Spawns/forks the slapd process
  340. """
  341. urls = [self.ldap_uri]
  342. if self.ldapi_uri:
  343. urls.append(self.ldapi_uri)
  344. slapd_args = [
  345. self.PATH_SLAPD,
  346. '-f', self._slapd_conf,
  347. '-F', self.testrundir,
  348. '-h', ' '.join(urls),
  349. ]
  350. if self._log.isEnabledFor(logging.DEBUG):
  351. slapd_args.extend(['-d', '-1'])
  352. else:
  353. slapd_args.extend(['-d', '0'])
  354. self._log.info('starting slapd: %r', ' '.join(slapd_args))
  355. self._proc = subprocess.Popen(slapd_args)
  356. # Waits until the LDAP server socket is open, or slapd crashed
  357. # no cover to avoid spurious coverage changes, see
  358. # https://github.com/python-ldap/python-ldap/issues/127
  359. for _ in range(10): # pragma: no cover
  360. if self._proc.poll() is not None:
  361. self._stopped()
  362. raise RuntimeError("slapd exited before opening port")
  363. time.sleep(self._start_sleep)
  364. try:
  365. self._log.debug(
  366. "slapd connection check to %s", self.default_ldap_uri
  367. )
  368. self.ldapwhoami()
  369. except RuntimeError:
  370. pass
  371. else:
  372. return
  373. raise RuntimeError("slapd did not start properly")
  374. def start(self):
  375. """
  376. Starts the slapd server process running, and waits for it to come up.
  377. """
  378. if self._proc is None:
  379. # prepare directory structure
  380. atexit.register(self.stop)
  381. self._cleanup_rundir()
  382. self.setup_rundir()
  383. self._write_config()
  384. self._test_config()
  385. self._start_slapd()
  386. self._log.debug(
  387. 'slapd with pid=%d listening on %s and %s',
  388. self._proc.pid, self.ldap_uri, self.ldapi_uri
  389. )
  390. def stop(self):
  391. """
  392. Stops the slapd server, and waits for it to terminate and cleans up
  393. """
  394. if self._proc is not None:
  395. self._log.debug('stopping slapd with pid %d', self._proc.pid)
  396. self._proc.terminate()
  397. self.wait()
  398. self._cleanup_rundir()
  399. if hasattr(atexit, 'unregister'):
  400. # Python 3
  401. atexit.unregister(self.stop)
  402. elif hasattr(atexit, '_exithandlers'):
  403. # Python 2, can be None during process shutdown
  404. try:
  405. atexit._exithandlers.remove(self.stop)
  406. except ValueError:
  407. pass
  408. def restart(self):
  409. """
  410. Restarts the slapd server with same data
  411. """
  412. self._proc.terminate()
  413. self.wait()
  414. self._start_slapd()
  415. def wait(self):
  416. """Waits for the slapd process to terminate by itself."""
  417. if self._proc:
  418. self._proc.wait()
  419. self._stopped()
  420. def _stopped(self):
  421. """Called when the slapd server is known to have terminated"""
  422. if self._proc is not None:
  423. self._log.info('slapd[%d] terminated', self._proc.pid)
  424. self._proc = None
  425. def _cli_auth_args(self):
  426. if self.cli_sasl_external:
  427. authc_args = [
  428. '-Y', 'EXTERNAL',
  429. ]
  430. if not self._log.isEnabledFor(logging.DEBUG):
  431. authc_args.append('-Q')
  432. else:
  433. authc_args = [
  434. '-x',
  435. '-D', self.root_dn,
  436. '-w', self.root_pw,
  437. ]
  438. return authc_args
  439. # no cover to avoid spurious coverage changes
  440. def _cli_popen(self, ldapcommand, extra_args=None, ldap_uri=None,
  441. stdin_data=None): # pragma: no cover
  442. if ldap_uri is None:
  443. ldap_uri = self.default_ldap_uri
  444. args = [
  445. ldapcommand,
  446. '-H', ldap_uri,
  447. ] + self._cli_auth_args() + (extra_args or [])
  448. self._log.debug('Run command: %r', ' '.join(args))
  449. proc = subprocess.Popen(
  450. args, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
  451. stderr=subprocess.PIPE
  452. )
  453. self._log.debug('stdin_data=%r', stdin_data)
  454. stdout_data, stderr_data = proc.communicate(stdin_data)
  455. if stdout_data is not None:
  456. self._log.debug('stdout_data=%r', stdout_data)
  457. if stderr_data is not None:
  458. self._log.debug('stderr_data=%r', stderr_data)
  459. if proc.wait() != 0:
  460. raise RuntimeError(
  461. '{!r} process failed:\n{!r}\n{!r}'.format(
  462. args, stdout_data, stderr_data
  463. )
  464. )
  465. return stdout_data, stderr_data
  466. def ldapwhoami(self, extra_args=None):
  467. """
  468. Runs ldapwhoami on this slapd instance
  469. """
  470. self._cli_popen(self.PATH_LDAPWHOAMI, extra_args=extra_args)
  471. def ldapadd(self, ldif, extra_args=None):
  472. """
  473. Runs ldapadd on this slapd instance, passing it the ldif content
  474. """
  475. self._cli_popen(self.PATH_LDAPADD, extra_args=extra_args,
  476. stdin_data=ldif.encode('utf-8'))
  477. def ldapmodify(self, ldif, extra_args=None):
  478. """
  479. Runs ldapadd on this slapd instance, passing it the ldif content
  480. """
  481. self._cli_popen(self.PATH_LDAPMODIFY, extra_args=extra_args,
  482. stdin_data=ldif.encode('utf-8'))
  483. def ldapdelete(self, dn, recursive=False, extra_args=None):
  484. """
  485. Runs ldapdelete on this slapd instance, deleting 'dn'
  486. """
  487. if extra_args is None:
  488. extra_args = []
  489. if recursive:
  490. extra_args.append('-r')
  491. extra_args.append(dn)
  492. self._cli_popen(self.PATH_LDAPDELETE, extra_args=extra_args)
  493. def __enter__(self):
  494. self.start()
  495. return self
  496. def __exit__(self, exc_type, exc_value, traceback):
  497. self.stop()
  498. class SlapdTestCase(unittest.TestCase):
  499. """
  500. test class which also clones or initializes a running slapd
  501. """
  502. server_class = SlapdObject
  503. server = None
  504. ldap_object_class = None
  505. def _open_ldap_conn(self, who=None, cred=None, **kwargs):
  506. """
  507. return a LDAPObject instance after simple bind
  508. """
  509. ldap_conn = self.ldap_object_class(self.server.ldap_uri, **kwargs)
  510. ldap_conn.protocol_version = 3
  511. #ldap_conn.set_option(ldap.OPT_REFERRALS, 0)
  512. ldap_conn.simple_bind_s(who or self.server.root_dn, cred or self.server.root_pw)
  513. return ldap_conn
  514. @classmethod
  515. def setUpClass(cls):
  516. cls.server = cls.server_class()
  517. cls.server.start()
  518. @classmethod
  519. def tearDownClass(cls):
  520. cls.server.stop()