|
| 1 | + |
| 2 | +""" |
| 3 | +Utilities for starting up a test slapd server |
| 4 | +and talking to it with ldapsearch/ldapadd. |
| 5 | +""" |
| 6 | + |
| 7 | +import sys, os, socket, time, subprocess, logging |
| 8 | + |
| 9 | +_log = logging.getLogger("slapd") |
| 10 | + |
| 11 | +def quote(s): |
| 12 | + '''Quotes the '"' and '\' characters in a string and surrounds with "..."''' |
| 13 | + return '"' + s.replace('\\','\\\\').replace('"','\\"') + '"' |
| 14 | + |
| 15 | +def mkdirs(path): |
| 16 | + """Creates the directory path unless it already exists""" |
| 17 | + if not os.access(os.path.join(path, os.path.curdir), os.F_OK): |
| 18 | + _log.debug("creating temp directory %s", path) |
| 19 | + os.mkdir(path) |
| 20 | + return path |
| 21 | + |
| 22 | +def delete_directory_content(path): |
| 23 | + for dirpath,dirnames,filenames in os.walk(path, topdown=False): |
| 24 | + for n in filenames: |
| 25 | + _log.info("remove %s", os.path.join(dirpath, n)) |
| 26 | + os.remove(os.path.join(dirpath, n)) |
| 27 | + for n in dirnames: |
| 28 | + _log.info("rmdir %s", os.path.join(dirpath, n)) |
| 29 | + os.rmdir(os.path.join(dirpath, n)) |
| 30 | + |
| 31 | +LOCALHOST = '127.0.0.1' |
| 32 | + |
| 33 | +def find_available_tcp_port(host=LOCALHOST): |
| 34 | + s = socket.socket() |
| 35 | + s.bind((host, 0)) |
| 36 | + port = s.getsockname()[1] |
| 37 | + s.close() |
| 38 | + _log.info("Found available port %d", port) |
| 39 | + return port |
| 40 | + |
| 41 | +class Slapd: |
| 42 | + """ |
| 43 | + Controller class for a slapd instance, OpenLDAP's server. |
| 44 | +
|
| 45 | + This class creates a temporary data store for slapd, runs it |
| 46 | + on a private port, and initialises it with a top-level dc and |
| 47 | + the root user. |
| 48 | +
|
| 49 | + When a reference to an instance of this class is lost, the slapd |
| 50 | + server is shut down. |
| 51 | + """ |
| 52 | + |
| 53 | + _log = logging.getLogger("Slapd") |
| 54 | + |
| 55 | + # Use /var/tmp to placate apparmour on Ubuntu: |
| 56 | + PATH_TMPDIR = "/var/tmp/python-ldap-test" |
| 57 | + PATH_SBINDIR = "/usr/sbin" |
| 58 | + PATH_BINDIR = "/usr/bin" |
| 59 | + PATH_SCHEMA_CORE = "/etc/ldap/schema/core.schema" |
| 60 | + PATH_LDAPADD = os.path.join(PATH_BINDIR, "ldapadd") |
| 61 | + PATH_LDAPSEARCH = os.path.join(PATH_BINDIR, "ldapsearch") |
| 62 | + PATH_SLAPD = os.path.join(PATH_SBINDIR, "slapd") |
| 63 | + PATH_SLAPTEST = os.path.join(PATH_SBINDIR, "slaptest") |
| 64 | + |
| 65 | + # TODO add paths for other OSs |
| 66 | + |
| 67 | + def check_paths(cls): |
| 68 | + """ |
| 69 | + Checks that the configured executable paths look valid. |
| 70 | + If they don't, then logs warning messages (not errors). |
| 71 | + """ |
| 72 | + for name,path in ( |
| 73 | + ("slapd", cls.PATH_SLAPD), |
| 74 | + ("ldapadd", cls.PATH_LDAPADD), |
| 75 | + ("ldapsearch", cls.PATH_LDAPSEARCH), |
| 76 | + ): |
| 77 | + cls._log.debug("checking %s executable at %s", name, path) |
| 78 | + if not os.access(path, os.X_OK): |
| 79 | + cls._log.warn("cannot find %s executable at %s", name, path) |
| 80 | + check_paths = classmethod(check_paths) |
| 81 | + |
| 82 | + def __init__(self): |
| 83 | + self._config = [] |
| 84 | + self._proc = None |
| 85 | + self._port = 0 |
| 86 | + self._tmpdir = self.PATH_TMPDIR |
| 87 | + self._dn_suffix = "dc=python-ldap,dc=org" |
| 88 | + self._root_cn = "Manager" |
| 89 | + self._root_password = "password" |
| 90 | + self._slapd_debug_level = 0 |
| 91 | + |
| 92 | + # Setters |
| 93 | + def set_port(self, port): |
| 94 | + self._port = port |
| 95 | + def set_dn_suffix(self, dn): |
| 96 | + self._dn_suffix = dn |
| 97 | + def set_root_cn(self, cn): |
| 98 | + self._root_cn = cn |
| 99 | + def set_root_password(self, pw): |
| 100 | + self._root_password = pw |
| 101 | + def set_tmpdir(self, path): |
| 102 | + self._tmpdir = path |
| 103 | + def set_slapd_debug_level(self, level): |
| 104 | + self._slapd_debug_level = level |
| 105 | + def set_debug(self): |
| 106 | + self._log.setLevel(logging.DEBUG) |
| 107 | + self.set_slapd_debug_level('Any') |
| 108 | + |
| 109 | + # getters |
| 110 | + def get_url(self): |
| 111 | + return "ldap://%s:%d/" % self.get_address() |
| 112 | + def get_address(self): |
| 113 | + if self._port == 0: |
| 114 | + self._port = find_available_tcp_port(LOCALHOST) |
| 115 | + return (LOCALHOST, self._port) |
| 116 | + def get_dn_suffix(self): |
| 117 | + return self._dn_suffix |
| 118 | + def get_root_dn(self): |
| 119 | + return "cn=" + self._root_cn + "," + self.get_dn_suffix() |
| 120 | + def get_root_password(self): |
| 121 | + return self._root_password |
| 122 | + def get_tmpdir(self): |
| 123 | + return self._tmpdir |
| 124 | + |
| 125 | + def __del__(self): |
| 126 | + self.stop() |
| 127 | + |
| 128 | + def configure(self, cfg): |
| 129 | + """ |
| 130 | + Appends slapd.conf configuration lines to cfg. |
| 131 | + Also re-initializes any backing storage. |
| 132 | + Feel free to subclass and override this method. |
| 133 | + """ |
| 134 | + |
| 135 | + # Global |
| 136 | + cfg.append("include " + quote(self.PATH_SCHEMA_CORE)) |
| 137 | + cfg.append("allow bind_v2") |
| 138 | + |
| 139 | + # Database |
| 140 | + ldif_dir = mkdirs(os.path.join(self.get_tmpdir(), "ldif-data")) |
| 141 | + delete_directory_content(ldif_dir) # clear it out |
| 142 | + cfg.append("database ldif") |
| 143 | + cfg.append("directory " + quote(ldif_dir)) |
| 144 | + |
| 145 | + cfg.append("suffix " + quote(self.get_dn_suffix())) |
| 146 | + cfg.append("rootdn " + quote(self.get_root_dn())) |
| 147 | + cfg.append("rootpw " + quote(self.get_root_password())) |
| 148 | + |
| 149 | + def _write_config(self): |
| 150 | + """Writes the slapd.conf file out, and returns the path to it.""" |
| 151 | + path = os.path.join(self._tmpdir, "slapd.conf") |
| 152 | + ldif_dir = mkdirs(self._tmpdir) |
| 153 | + if os.access(path, os.F_OK): |
| 154 | + self._log.debug("deleting existing %s", path) |
| 155 | + os.remove(path) |
| 156 | + self._log.debug("writing config to %s", path) |
| 157 | + file(path, "w").writelines([line + "\n" for line in self._config]) |
| 158 | + return path |
| 159 | + |
| 160 | + def start(self): |
| 161 | + """ |
| 162 | + Starts the slapd server process running, and waits for it to come up. |
| 163 | + """ |
| 164 | + if self._proc is None: |
| 165 | + ok = False |
| 166 | + config_path = None |
| 167 | + try: |
| 168 | + self.configure(self._config) |
| 169 | + self._test_configuration() |
| 170 | + self._start_slapd() |
| 171 | + self._wait_for_slapd() |
| 172 | + ok = True |
| 173 | + self._log.debug("slapd ready at %s", self.get_url()) |
| 174 | + self.started() |
| 175 | + finally: |
| 176 | + if not ok: |
| 177 | + if config_path: |
| 178 | + try: os.remove(config_path) |
| 179 | + except os.error: pass |
| 180 | + if self._proc: |
| 181 | + self.stop() |
| 182 | + |
| 183 | + def _start_slapd(self): |
| 184 | + # Spawns/forks the slapd process |
| 185 | + config_path = self._write_config() |
| 186 | + self._log.info("starting slapd") |
| 187 | + self._proc = subprocess.Popen([self.PATH_SLAPD, |
| 188 | + "-f", config_path, |
| 189 | + "-h", self.get_url(), |
| 190 | + "-d", str(self._slapd_debug_level), |
| 191 | + ]) |
| 192 | + self._proc_config = config_path |
| 193 | + |
| 194 | + def _wait_for_slapd(self): |
| 195 | + # Waits until the LDAP server socket is open, or slapd crashed |
| 196 | + s = socket.socket() |
| 197 | + while 1: |
| 198 | + if self._proc.poll() is not None: |
| 199 | + self._stopped() |
| 200 | + raise RuntimeError("slapd exited before opening port") |
| 201 | + try: |
| 202 | + self._log.debug("Connecting to %s", repr(self.get_address())) |
| 203 | + s.connect(self.get_address()) |
| 204 | + s.close() |
| 205 | + return |
| 206 | + except socket.error: |
| 207 | + time.sleep(1) |
| 208 | + |
| 209 | + def stop(self): |
| 210 | + """Stops the slapd server, and waits for it to terminate""" |
| 211 | + if self._proc is not None: |
| 212 | + self._log.debug("stopping slapd") |
| 213 | + if hasattr(self._proc, 'terminate'): |
| 214 | + self._proc.terminate() |
| 215 | + else: |
| 216 | + import posix, signal |
| 217 | + posix.kill(self._proc.pid, signal.SIGHUP) |
| 218 | + #time.sleep(1) |
| 219 | + #posix.kill(self._proc.pid, signal.SIGTERM) |
| 220 | + #posix.kill(self._proc.pid, signal.SIGKILL) |
| 221 | + self.wait() |
| 222 | + |
| 223 | + def restart(self): |
| 224 | + """ |
| 225 | + Restarts the slapd server; ERASING previous content. |
| 226 | + Starts the server even it if isn't already running. |
| 227 | + """ |
| 228 | + self.stop() |
| 229 | + self.start() |
| 230 | + |
| 231 | + def wait(self): |
| 232 | + """Waits for the slapd process to terminate by itself.""" |
| 233 | + if self._proc: |
| 234 | + self._proc.wait() |
| 235 | + self._stopped() |
| 236 | + |
| 237 | + def _stopped(self): |
| 238 | + """Called when the slapd server is known to have terminated""" |
| 239 | + if self._proc is not None: |
| 240 | + self._log.info("slapd terminated") |
| 241 | + self._proc = None |
| 242 | + try: |
| 243 | + os.remove(self._proc_config) |
| 244 | + except os.error: |
| 245 | + self._log.debug("could not remove %s", self._proc_config) |
| 246 | + |
| 247 | + def _test_configuration(self): |
| 248 | + config_path = self._write_config() |
| 249 | + try: |
| 250 | + self._log.debug("testing configuration") |
| 251 | + verboseflag = "-Q" |
| 252 | + if self._log.isEnabledFor(logging.DEBUG): |
| 253 | + verboseflag = "-v" |
| 254 | + p = subprocess.Popen([ |
| 255 | + self.PATH_SLAPTEST, |
| 256 | + verboseflag, |
| 257 | + "-f", config_path |
| 258 | + ]) |
| 259 | + if p.wait() != 0: |
| 260 | + raise RuntimeError("configuration test failed") |
| 261 | + self._log.debug("configuration seems ok") |
| 262 | + finally: |
| 263 | + os.remove(config_path) |
| 264 | + |
| 265 | + def ldapadd(self, ldif, extra_args=[]): |
| 266 | + """Runs ldapadd on this slapd instance, passing it the ldif content""" |
| 267 | + self._log.debug("adding %s", repr(ldif)) |
| 268 | + p = subprocess.Popen([self.PATH_LDAPADD, |
| 269 | + "-x", |
| 270 | + "-D", self.get_root_dn(), |
| 271 | + "-w", self.get_root_password(), |
| 272 | + "-H", self.get_url()] + extra_args, |
| 273 | + stdin = subprocess.PIPE, stdout=subprocess.PIPE) |
| 274 | + p.communicate(ldif) |
| 275 | + if p.wait() != 0: |
| 276 | + raise RuntimeError("ldapadd process failed") |
| 277 | + |
| 278 | + def ldapsearch(self, base=None, filter='(objectClass=*)', attrs=[], |
| 279 | + scope='sub', extra_args=[]): |
| 280 | + if base is None: base = self.get_dn_suffix() |
| 281 | + self._log.debug("ldapsearch filter=%s", repr(filter)) |
| 282 | + p = subprocess.Popen([self.PATH_LDAPSEARCH, |
| 283 | + "-x", |
| 284 | + "-D", self.get_root_dn(), |
| 285 | + "-w", self.get_root_password(), |
| 286 | + "-H", self.get_url(), |
| 287 | + "-b", base, |
| 288 | + "-s", scope, |
| 289 | + "-LL", |
| 290 | + ] + extra_args + [ filter ] + attrs, |
| 291 | + stdout = subprocess.PIPE) |
| 292 | + output = p.communicate()[0] |
| 293 | + if p.wait() != 0: |
| 294 | + raise RuntimeError("ldapadd process failed") |
| 295 | + |
| 296 | + # RFC 2849: LDIF format |
| 297 | + # unfold |
| 298 | + lines = [] |
| 299 | + for l in output.split('\n'): |
| 300 | + if l.startswith(' '): |
| 301 | + lines[-1] = lines[-1] + l[1:] |
| 302 | + elif l == '' and lines and lines[-1] == '': |
| 303 | + pass # ignore multiple blank lines |
| 304 | + else: |
| 305 | + lines.append(l) |
| 306 | + # Remove comments |
| 307 | + lines = [l for l in lines if not l.startswith("#")] |
| 308 | + |
| 309 | + # Remove leading version and blank line(s) |
| 310 | + if lines and lines[0] == '': del lines[0] |
| 311 | + if not lines or lines[0] != 'version: 1': |
| 312 | + raise RuntimeError("expected 'version: 1', got " + repr(lines[:1])) |
| 313 | + del lines[0] |
| 314 | + if lines and lines[0] == '': del lines[0] |
| 315 | + |
| 316 | + # ensure the ldif ends with a blank line (unless it is just blank) |
| 317 | + if lines and lines[-1] != '': lines.append('') |
| 318 | + |
| 319 | + objects = [] |
| 320 | + obj = [] |
| 321 | + for line in lines: |
| 322 | + if line == '': # end of an object |
| 323 | + if obj[0][0] != 'dn': |
| 324 | + raise RuntimeError("first line not dn", repr(obj)) |
| 325 | + objects.append((obj[0][1], obj[1:])) |
| 326 | + obj = [] |
| 327 | + else: |
| 328 | + attr,value = line.split(':',2) |
| 329 | + if value.startswith(': '): |
| 330 | + value = base64.decodestring(value[2:]) |
| 331 | + elif value.startswith(' '): |
| 332 | + value = value[1:] |
| 333 | + else: |
| 334 | + raise RuntimeError("bad line: " + repr(line)) |
| 335 | + obj.append((attr,value)) |
| 336 | + assert obj == [] |
| 337 | + return objects |
| 338 | + |
| 339 | + def started(self): |
| 340 | + """ |
| 341 | + This method is called when the LDAP server has started up and is empty. |
| 342 | + By default, this method adds the two initial objects, |
| 343 | + the domain object and the root user object. |
| 344 | + """ |
| 345 | + assert self.get_dn_suffix().startswith("dc=") |
| 346 | + suffix_dc = self.get_dn_suffix().split(',')[0][3:] |
| 347 | + assert self.get_root_dn().startswith("cn=") |
| 348 | + assert self.get_root_dn().endswith("," + self.get_dn_suffix()) |
| 349 | + root_cn = self.get_root_dn().split(',')[0][3:] |
| 350 | + |
| 351 | + self._log.debug("adding %s and %s", |
| 352 | + self.get_dn_suffix(), |
| 353 | + self.get_root_dn()) |
| 354 | + |
| 355 | + self.ldapadd("\n".join([ |
| 356 | + 'dn: ' + self.get_dn_suffix(), |
| 357 | + 'objectClass: dcObject', |
| 358 | + 'objectClass: organization', |
| 359 | + 'dc: ' + suffix_dc, |
| 360 | + 'o: ' + suffix_dc, |
| 361 | + '', |
| 362 | + 'dn: ' + self.get_root_dn(), |
| 363 | + 'objectClass: organizationalRole', |
| 364 | + 'cn: ' + root_cn, |
| 365 | + '' |
| 366 | + ])) |
| 367 | + |
| 368 | +Slapd.check_paths() |
| 369 | + |
| 370 | +if __name__ == '__main__' and sys.argv == ['run']: |
| 371 | + logging.basicConfig(level=logging.DEBUG) |
| 372 | + slapd = Slapd() |
| 373 | + print("Starting slapd...") |
| 374 | + slapd.start() |
| 375 | + print("Contents of LDAP server follow:\n") |
| 376 | + for dn,attrs in slapd.ldapsearch(): |
| 377 | + print("dn: " + dn) |
| 378 | + for name,val in attrs: |
| 379 | + print(name + ": " + val) |
| 380 | + print("") |
| 381 | + print(slapd.get_url()) |
| 382 | + slapd.wait() |
| 383 | + |
0 commit comments