commit inicial
This commit is contained in:
486
action_plugins/ldap.py
Normal file
486
action_plugins/ldap.py
Normal file
@@ -0,0 +1,486 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user