# -*- coding: utf-8 -*- from __future__ import (absolute_import, division, print_function) __metaclass__ = type from ansible.plugins.action import ActionBase from ansible.errors import AnsibleError from ansible.utils.vars import merge_hash from ansible.module_utils._text import to_text from ansible.module_utils.parsing.convert_bool import boolean import sys import re import base64 import io try: import ldap import ldif HAS_LDAP = True class MyLDIF(ldif.LDIFParser): '''Clase auxiliar para extraer información de LDIF''' def __init__(self,input): ldif.LDIFParser.__init__(self,input) self._entries = [] def handle(self,dn,entry): self._entries.append((dn,entry)) def entries(self): return self._entries except ImportError: HAS_LDAP = False class LDAPException(Exception): '''Excepción genérica de este módulo''' pass # ---- funciones para chequeo de claves, sacado de # http://www.openldap.org/faq/data/cache/347.html import os import hashlib from base64 import encodestring as b64encode from base64 import decodestring as b64decode def makeSecret(password): salt = os.urandom(4) h = hashlib.sha1(password) h.update(salt) return "{SSHA}" + b64encode(h.digest() + salt)[:-1] def checkPassword(challenge_password, password): try: challenge_bytes = b64decode(challenge_password[6:]) digest = challenge_bytes[:20] salt = challenge_bytes[20:] hr = hashlib.sha1(password) hr.update(salt) return digest == hr.digest() except: pass return False class ActionModule(ActionBase): # Nombre de los argumentos ARG_DN = 'dn' ARG_OBJCLS = 'objectClass' ARG_ATTRS = 'attributes' ARG_FILTER = 'filter' ARG_LDIF = 'from_ldif' ARG_REMOTE = 'remote_src' ARG_RDN = 'dn_relative' ARG_CURLY = 'dn_curly_hack' ARG_BINDDN = 'bind_dn' ARG_BINDPW = 'bind_pw' ARG_SRVURI = 'server_uri' ARG_TLS = 'start_tls' ARG_VALCRT = 'validate_certs' ARG_STYPE = 'search_type' ARG_STATE = 'state' # Atributos que representan claves ATTR_PASSWORD = ('olcRootPW', 'userPassword') # Atributos que no se pueden eliminar ATTR_FRAGILE = ('olcModuleLoad','olcAttributeTypes') # Atributos RDN, no se pueden modificar ATTR_RDN = () def __init__(self, *args, **kwargs): super(ActionModule, self).__init__(*args,**kwargs) self.result= dict(failed=False,changed=False,action_history=[]) self.dn = None self.attrs = dict() self.entries = [] def _read_args(self): ''' Leer argumentos y efectuar búsqueda inicial del DN ''' # Leer DN y parametros de busqueda self.req_dn = self._task.args.get(self.ARG_DN, None) self.dn_relative = self._task.args.get(self.ARG_RDN, False) self.search_filter = self._task.args.get(self.ARG_FILTER, '(objectClass=*)') self.search_type = self._task.args.get(self.ARG_STYPE, 'sub') self.state = self._task.args.get(self.ARG_STATE, 'present') self.oc = self._task.args.get(self.ARG_OBJCLS, None) self.req_attrs = self._task.args.get(self.ARG_ATTRS, {}) if self._task.args.get(self.ARG_LDIF, []): '''Leer LDIF pasado como argumento y usarlo para configurar entrada''' self._load_from_ldif() if self.dn_relative: '''Setear base de busqueda al parent de req_dn''' reqdn = ldap.dn.str2dn(self.req_dn) rdnkey = reqdn[0][0][0] rdnval = reqdn[0][0][1] if boolean(self._task.args.get(self.ARG_CURLY,False), strict=False): rdnval = '{*}' + rdnval self.search_filter = '(&{}({}={}))'.format( self.search_filter, rdnkey, rdnval) self.search_base = ldap.dn.dn2str(reqdn[1:]) else: self.search_base = self.req_dn # Validar params obligatorios if not self.req_dn: raise LDAPException("'{}' argument is mandatory, got value: {}".format(self.ARG_DN,self.req_dn)) if not self.oc: raise LDAPException("'{}' argument is mandatory".format(self.ARG_OBJCLS)) # parametros de conexion al servidor self.bdn = self._task.args.get(self.ARG_BINDDN, None) self.bpw = self._task.args.get(self.ARG_BINDPW, None) self.uri = self._task.args.get(self.ARG_SRVURI, 'ldapi:///') self.tls = self._task.args.get(self.ARG_TLS, False) self.valcert = self._task.args.get(self.ARG_VALCRT, True) # Armar comando ldapsearch self.ldapsearch_cmd = 'ldapsearch -LLL -H {}'.format(self.uri) if self.bdn and self.bpw: self.ldapsearch_cmd = '{} -D {} -w "{}"'.format( self.ldapsearch_cmd, self.bdn, self.bpw) else: self.ldapsearch_cmd = "{} -Y EXTERNAL".format( self.ldapsearch_cmd) if not self.valcert: self.ldapsearch_cmd = 'LDAPTLS_REQCERT=never {}'.format(self.ldapsearch_cmd) if self.tls: self.ldapsearch_cmd = '{} -ZZ'.format(self.ldapsearch_cmd) # Armar parametros para módulos ldap_entry, ldap_attr self.ldap_params = { 'bind_dn': self.bdn, 'bind_pw': self.bpw, 'server_uri': self.uri, 'start_tls': self.tls, 'validate_certs': self.valcert } def _load_from_ldif(self): '''Configurar argumentos a partir de archivo LDIF''' # copiado en buena parte de https://github.com/ansible/ansible/blob/devel/lib/ansible/plugins/action/unarchive.py source = self._task.args.get(self.ARG_LDIF) remote_src = boolean(self._task.args.get(self.ARG_REMOTE, False), strict=False) ldif_content = io.StringIO() # el ldif es local if not remote_src: '''encontrar nombre real del archivo''' try: source = self._loader.get_real_file(self._find_needle('files',source)) except AnsibleError as e: raise AnsibleActionFail(to_text(e)) '''leer archivo''' with open(source,'r') as f: ldif_content.write(unicode(f.read())) # el ldif es remoto else: try: slurp = self._execute_module(module_name='slurp',module_args=dict(src=source)) if slurp.get('failed',False): raise AnsibleError('Failed reading remote file!') if slurp.get('encoding') == 'base64': ldif_content.write(unicode(b64decode(slurp['content']))) else: raise AnsibleError('Unknown encoding returned from slurp, this is a bug.') except AnsibleError as e: raise AnsibleActionFail('Error reading remote file: {}'.format(e.message)) ldif_content.write(u'\n\n') ldif_content.seek(0) parser = MyLDIF(ldif_content) parser.parse() # Sólo se soporta UNA entrada por archivo LDIF entry = parser.entries()[0] self.result['entry'] = entry self.req_dn = entry[0] self.req_attrs = entry[1].copy() del self.req_attrs['objectClass'] self.oc = entry[1].get('objectClass',None) # self.result['req_attr'] = self.req_attrs def _ldapsearch(self, args): '''Ejecutar comando ldapsearch y devolver el resultado''' command = "{} {}".format(self.ldapsearch_cmd,args) result = {} failed = False try: result = self._execute_module( module_name='command', module_args=dict(_raw_params=command)) failed = False if result['rc'] in (0, 32) else result.get('failed',False) except Exception as e: raise LDAPException("Failed command: {}, message: {}".format( command, e.message)) if failed: raise LDAPException("Failed command: {}, message: {}".format( command, result['stderr'])) result['failed'] = failed return result def _find_dn(self): ''' Encontrar DN que matchea rdnkey=rdnval dentro del DN self.parent, aplicando el filtro de búsqueda en self.sfilter. ''' self.search_type = 'base' if self.dn_relative: self.search_type = 'one' # Ejecutar comando entries = self.search() # DN encontrado founddn = None if len(entries): founddn = entries[0][0] # registrar resultados y salir self.result['action_history'].append({ 'function': 'finddn', 'outcome': ('found' if founddn else 'not found'), }) self.dn = founddn def search(self): ''' Efectuar búsqueda, retornar entrada(s) encontradas ''' qfilter = '(objectClass=*)' # armar el filtro de búsqueda agregando los filtros adicionales if isinstance(self.search_filter, basestring): qfilter = self.search_filter else: for f in self.search_filter: qfilter = '(&{}{})'.format(qfilter,f) # efectuar búsqueda mediante ldapsearch cmd_args = '-s {} -b "{}" "{}"'.format(self.search_type, self.search_base, qfilter) result = self._ldapsearch(cmd_args) entries = [] if result['rc'] in (0,32): # procesar la salida LDIF ldif_out = io.StringIO(result['stdout']+'\n\n') parser = MyLDIF(ldif_out) parser.parse() entries = parser.entries() self.result['action_history'].append({ 'function': 'search', 'outcome': ('found' if len(entries) else 'no results found'), 'arguments': cmd_args }) else: self.result['action_history'].append({ 'function': 'search', 'outcome': 'failed', 'arguments': cmd_args }) return entries def add_entry(self): ''' Agregar entrada al directorio llamando al módulo ldap_entry. Los datos se obtienen de los argumentos pasados al módulo. ''' attrs = dict(self.req_attrs) for attr in attrs.keys(): if attr in self.ATTR_PASSWORD: attrs[attr] = makeSecret(attrs[attr]) result = {} failed = False changed = False args = dict( dn=self.req_dn, objectClass=self.oc, attributes=attrs, params = self.ldap_params ) try: # llamar a ldap_entry con argumentos dn, objectClass, attributes result = self._execute_module( module_name='ldap_entry', module_args=args ) failed = result.get('failed',False) changed = result.get('changed',False) except Exception as e: raise LDAPException("Error invoking ldap_entry: {}: {}".format( type(e).__name__,to_text(e))) if failed: raise LDAPException("Error invoking ldap_entry, args: {}, message: {}".format( args, result)) # registrar resultados if changed: self.result['changed'] = True self.result['action_history'].append({ 'function': 'add_entry', 'outcome': 'failed' if failed else 'success', 'result': result }) # actualizar self.dn con el DN de la entrada recién creada self._find_dn() def findattr(self): ''' Leer atributos de la entrada con ldapsearch. ''' # efectuar búsqueda cmd_args = '-s base -b "{}"'.format(self.dn) result = self._ldapsearch(cmd_args) # Leer LDIF resultante ldif_out = io.StringIO(result['stdout']) parser = MyLDIF(ldif_out) parser.parse() entries = parser.entries() # validar que el resultado se una única entrada if len(entries) != 1: raise LDAPException("Unable to read attributes for DN={}, cmd={}, entries={}".format(self.dn,cmd,entries)) if entries[0][0] != self.dn: raise LDAPException("Unexpected DN={} while reading entry attributes, expected {}".format( entries[0][0], self.dn)) # registrar invocación y guardar self.attrs self.result['action_history'].append({ 'function': 'findattr', 'outcome': 'success', 'result': result }) self.attrs = entries[0][1] def updateattr(self, attribute, value): '''Verificar/actualizar valor para un atributo''' if attribute in self.ATTR_PASSWORD: if not isinstance(value, basestring): value = value[0] if checkPassword(self.attrs.get(attribute,[None])[0], value): # si la clave matchea, salir self.result['action_history'].append({ 'function': 'updateattr', 'arguments': [attribute, 'hidden password'], 'outcome': 'password not changed' }) return else: # setear el valor al hash salteado value = [makeSecret(value)] # convertir el valor a una lista if isinstance(value, basestring): value = [value] changed = False failed = False result = None args = { 'dn': self.dn, 'name': attribute, 'values': value, 'state': 'present' if attribute in self.ATTR_FRAGILE else 'exact', 'params': self.ldap_params } if set(value) != set(self.attrs.get(attribute,[])): # actualizar valor llamando a ldap_attr try: result = self._execute_module( module_name='ldap_attr', module_args=args ) changed = result.get('changed',False) failed = result.get('failed',False) except Exception as e: raise LDAPException("Error invoking ldap_attr: {}: {}, result = {}".format( type(e).__name__,to_text(e),result)) # guardar resultados if changed: self.result['changed'] = True if failed: self.result['failed'] = True self.result['action_history'].append({ 'function': 'updateattr', 'arguments': [attribute, value], 'outcome': 'failed' if failed else ('changed' if changed else 'unchanged'), 'result': result }) def run(self, tmp=None, task_vars={}): '''Función principal, punto de entrada al plugin''' # Llamar a la función run de la clase madre result = super(ActionModule, self).run(tmp, task_vars) # Verificar si debo salir o fallar if result.get('skipped', False) or result.get('failed', False): return result # Asegurar que la importación LDAP no falló, salir con gracia if not HAS_LDAP: return merge_hash( self.result, { 'failed': True, 'msg': "Missing required 'ldap' module (pip install python-ldap)." }) try: # leer argumentos self._read_args() # si solo se requiere buscar, efectuar busqueda y salir if self.state == 'search': self.result.update(dict(entries=self.search())) return self.result # busqueda inicial del DN self._find_dn() if self.dn is None: # el DN no existe, crear la entrada self.add_entry() else: # el DN existe, verificar atributos attrs = self.findattr() # verificar/actualizar cada atributo for attr in self.req_attrs.keys(): self.updateattr(attr, self.req_attrs[attr]) except LDAPException as e: return merge_hash( self.result, { 'failed': True, 'msg': "Invalid arguments: {}".format(e.message) }) self.result['dn'] = self.dn self.result['attrs'] = self.attrs return self.result