491 lines
16 KiB
Python
491 lines
16 KiB
Python
# -*- 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 six
|
|
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.encode('utf-8'))
|
|
h.update(salt)
|
|
return "{SSHA}" + b64encode(h.digest() + salt)[:-1].decode()
|
|
|
|
def checkPassword(challenge_password, password):
|
|
try:
|
|
challenge_bytes = b64decode(challenge_password[6:])
|
|
digest = challenge_bytes[:20]
|
|
salt = challenge_bytes[20:]
|
|
hr = hashlib.sha1(password.encode('utf-8'))
|
|
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, six.string_types):
|
|
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, six.string_types):
|
|
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, six.string_types):
|
|
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:
|
|
import platform
|
|
return merge_hash( self.result, {
|
|
'failed': True,
|
|
'msg': "Missing required 'ldap' module (pip install python-ldap). "
|
|
"Python version: {}".format(platform.python_version())
|
|
})
|
|
|
|
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
|