From 978e63034289eb29014905544ff94ccf2443f66f Mon Sep 17 00:00:00 2001 From: Mauro Torrez Date: Sun, 7 Apr 2019 23:44:01 -0300 Subject: [PATCH] commit inicial --- .gitignore | 6 + README.md | 146 +++++++++++++ action_plugins/ldap.py | 486 +++++++++++++++++++++++++++++++++++++++++ defaults/main.yml | 94 ++++++++ files/Dockerfile | 40 ++++ files/entrypoint.sh | 85 +++++++ tasks/domain.yml | 304 ++++++++++++++++++++++++++ tasks/main.yml | 52 +++++ tasks/provider.yml | 82 +++++++ 9 files changed, 1295 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 action_plugins/ldap.py create mode 100644 defaults/main.yml create mode 100644 files/Dockerfile create mode 100755 files/entrypoint.sh create mode 100644 tasks/domain.yml create mode 100644 tasks/main.yml create mode 100644 tasks/provider.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c24c661 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*~ +\#* +.#* +*.pyc +*.bak +__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..177668a --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# Rol openldap-docker + +Este rol configurar un servicio OpenLDAP dentro de un container +Docker. Permite un esquema de replicación delta-syncrepl [deltasync]. + +El rol efectúa a grandes rasgos las siguientes tareas: + +1. _Configuración general_: setea clave del administrador + `cn=admin,cn=config` y carga esquemas. + +2. _Configuración de provider delta-syncrepl_: configura los módulos + `accesslog` y `syncprov` en el provider de replicación. + +3. _Configuración de dominios_: Hace un _loop_ sobre cada dominio + especificado en `openldap_domains`, configurando entradas de las + base de datos en `cn=config` y clientes de replicación del dominio. + +La siguientes consideraciones deben ser tenidas en cuenta al usar este rol: + +* Se deben especificar *primero* los dominios más generales + (`ejemplo.com.ar`) y luego los más específicos + (`sub.ejemplo.com.ar`). + +* La base `cn=accesslog` del provider requiere que se especifique un + usuario administrador en la variable `openldap_accesslog_admin_dn`. + Este usuario *debe* pertenecer a alguno de los dominios + configurados en el servidor. + +* Se debe setear una clave para el usuario administrador + `cn=admin,cn=config` en la variable `openldap_admin_password`. + +## Configuración del rol + +Las variables a continuación determinan el comportamiento del rol. +El valor por defecto se muestra entre paréntesis. + +### Configuraciones generales + + * `openldap_schemas`: Schemas a cargar. Cada schema personalizado se + debe agregar el LDIF correspondiente en + `files/schemas/NOMBRE.ldif` dentro del rol. Valor por defecto: + + ``` + - core + - cosine + - nis + - inetorgperson + - misc + ``` + + * `openldap_create_dit_entries` (`yes`): Especifica si se debe crear + el DIT: dominios, usuarios administradores, OUs, para cada + dominio. + +### Replicación + +A no ser que se seteen las variables a continuación, el rol no +configura replicación alguna. + + * `openldap_provider` (`no`): + En un esquema de replicación, el host es provider. + + * `openldap_consumer` (`no`): + En un esquema de replicación, el host es consumer. + + * `openldap_replicator_base` (`dc=example,dc=com`): + DN base a partir del cual se replica. + + * `openldap_accesslog_dir` (`/var/lib/ldap/accesslog`): + Directorio donde crear la base `cn=accesslog`. + +### Credenciales + + * `openldap_admin_password` (`password`): + Clave del usuario administrador `cn=admin,cn=config`. + + * `openldap_accesslog_admin_dn` (`cn=admin,dc=example,dc=com`): + DN del usuario administrador para la base `cn=accesslog`. + Se debe setear en el _provider_. + + * `openldap_replicator_dn` (`cn=replicator,dc=example,dc=com`): + DN del usuario usado para replicación. + Se debe setear en el _provider_ y en el _consumer_. + + * `openldap_replicator_password` (`password`): + Clave del usuario usado para replicación. + Se debe setear en el _provider_ y en el _consumer_. + +### Valores por defecto para los dominios + + * `openldap_default_db_access`: Lista con permisos de acceso. + Valor por defecto: + ``` + - {0}to attrs=userPassword by self write by anonymous auth by * none" + - {1}to attrs=shadowLastChange by self write by * read" + - {2}to * by * read" + ``` + + * `openldap_default_db_index`: Lista con índices del dominio. + Valor por defecto: + ``` + - cn,uid eq + - member,memberUid eq + - objectClass eq + - uidNumber,gidNumber eq + ``` + + * `openldap_default_db_limits`: Límites de acceso al dominio. + Valor por defecto: `[]` (lista vacía) + + * `openldap_default_domain_ous`: OUs a crear dentro del dominio. + Valor por defecto: + ``` + - Alias + - Group + - People + ``` + +### Configuración de dominios + +La configuración de los dominios se setea en la variable +`openldap_domains` (`[]`). Esta variable es una _lista de +diccionarios_ en la que cada item representa un dominio. + +Cada dominio (diccionario) tiene las siguientes claves: + + * `name`: Obligatorio. Nombre del dominio en notación `mi.dominio.com`. + Para este ejemplo se crea la organizacion `dc=mi,dc=ejemplo,dc=com`. + * `admincn` (`admin`): cn del administrador, cuyo DN vendrá dado por + `cn=admin,dc=mi,dc=ejemplo,dc=com`. + * `adminpw` (`password`): clave del administrador del dominio. + Se recomienda cambiar este valor, o setear la variable `adminpw_id`. + * `adminpw_id`: ID de la clave del administrador en Rattic. + Sobreescribe el valor de `adminpw` con la clave obtenida de Rattic. + * `access`: lista con permisos de acceso. + Por defecto toma el valor de la variable `openldap_default_db_access`. + * `ou`: unidades organizacionales del dominio. + Por defecto toma el valor de la variable `openldap_default_domain_ous`. + + + * Si bien se crea un usuario admin para los subdominios, éstos por + defecto no tienen permiso alguno. + +## Referencias + +[deltasync]: https://openldap.org/doc/admin24/replication.html#Delta-syncrepl diff --git a/action_plugins/ldap.py b/action_plugins/ldap.py new file mode 100644 index 0000000..c5707c0 --- /dev/null +++ b/action_plugins/ldap.py @@ -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 diff --git a/defaults/main.yml b/defaults/main.yml new file mode 100644 index 0000000..87cc352 --- /dev/null +++ b/defaults/main.yml @@ -0,0 +1,94 @@ +--- +# Indica si el host es provider en un esquema de replicación +openldap_provider: no + +# Nombre del provider para un host consumer +openldap_provider_host: null + +# Indica si el host es esclavo en un esquema de replicación +openldap_consumer: no + +# Clave del usuario cn=admin,cn=config +openldap_admin_password: password + +# DN del administrador de la base cn=accesslog (provider) +openldap_backup_dir: "/srv/backups/ldap" +openldap_backup_keep: 200 + +# DN del administrador de la base cn=accesslog (provider) +openldap_accesslog_admin_dn: cn=admin,dc=example,dc=com + +# DN, clave y search base del replicador +openldap_replicator_dn: cn=replicator,dc=example,dc=com +openldap_replicator_password: password +openldap_replicator_base: dc=example,dc=com + +# port in docker HOST to bind ldap service +openldap_bind_port: 10389 +openldap_bind_host: "localhost" + +# docker image name +openldap_image_name: "i-openldap" + +# docker container name +openldap_container_name: "c-openldap" + +# docker volume names +openldap_volume_config: "ldap_config" +openldap_volume_data: "ldap_data" +openldap_volume_backup: "ldap_backup" + +# permisos de acceso por defecto +openldap_default_db_access: + - "{0}to attrs=userPassword by self write by anonymous auth by * none" + - "{1}to attrs=shadowLastChange by self write by * read" + - "{2}to * by * read" + +# indices por defecto +openldap_default_db_index: + - "cn,uid eq" + - "member,memberUid eq" + - "objectClass eq" + - "uidNumber,gidNumber eq" + +# limites por defecto: ninguno +openldap_default_db_limits: [] + +# OUs creadas por defecto dentro de cada dominio +openldap_default_domain_ous: + - Alias + - Group + - People + +# Dominios a configurar: +# Cada dominio se especifica en un diccionario con las siguientes claves: +# - name: nombre del dominio (ejemplo.com) (obligatorio) +# a partir de este nombre se crea la organizacion dc=ejemplo,dc=com +# - admincn: nombre cn del administrador (defecto=admin) +# este cn deriva en un DN cn=admin,dc=ejemplo,dc=com +# - adminpw: clave del administrador de este dominio (defecto=password) +# SE RECOMIENDA CAMBIAR ESTE VALOR, O SETEAR ID RATTIC +# - access: lista con permisos de acceso. por defecto es la lista definida +# en la variable openldap_default_db_access +# - ou: unidades organizacionales del dominio, defecto openldap_default_domain_ous +openldap_domains: [] + # - name: unl.edu.ar + # - name: rectorado.unl.edu.ar + # - name: servicios.unl.edu.ar + +# Directorio donde crear la base cn=accesslog +openldap_accesslog_dir: "/var/lib/ldap/accesslog" + +# esquemas a cargar +openldap_schemas: + - core + - cosine + - nis + - inetorgperson + - misc + +# crear entradas en el DIT? (dominios, administradores, OUs) +openldap_create_dit_entries: yes + +# habilitar modulo memberof? +openldap_enable_memberof: yes diff --git a/files/Dockerfile b/files/Dockerfile new file mode 100644 index 0000000..9b6caee --- /dev/null +++ b/files/Dockerfile @@ -0,0 +1,40 @@ +FROM debian:stable-slim + +MAINTAINER Mauro Torrez + +ENV OPENLDAP_ROOT_PASSWORD="root" + +# space-separated list of schemas +ENV OPENLDAP_SCHEMAS="misc" + +ENV OPENLDAP_BACKUP_MIN="0" +ENV OPENLDAP_BACKUP_HOUR="1" +ENV OPENLDAP_BACKUP_DOM="*" +ENV OPENLDAP_BACKUP_MON="*" +ENV OPENLDAP_BACKUP_DOW="*" +# TODO configurar Cron de backup + +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + slapd \ + ldap-utils && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +COPY entrypoint.sh /entrypoint.sh + +# TODO backup and restore +# ADD ldap_backup.sh /usr/local/sbin/ldap_backup.sh +# ADD ldap_restore.sh /usr/local/sbin/ldap_restore.sh + +# add my_custom_schema: install by setting OPENLDAP_SCHEMAS=my_custom_schema +# COPY my_custom_schema.ldif /etc/ldap/schema/my_custom_schema.ldif + +EXPOSE 389 + +VOLUME ["/etc/ldap/slapd.d", "/var/lib/ldap", "/var/backups/ldap"] + +ENTRYPOINT ["/bin/bash", "/entrypoint.sh"] +# log level info: + +CMD ["slapd", "-d", "32768", "-u", "openldap", "-g", "openldap"] diff --git a/files/entrypoint.sh b/files/entrypoint.sh new file mode 100755 index 0000000..7f33903 --- /dev/null +++ b/files/entrypoint.sh @@ -0,0 +1,85 @@ +#!/bin/bash +msg(){ ${VERBOSE:-true} && echo ${@} ; } +assert(){ [[ $? -eq 0 ]] || { [[ -n ${1} ]] && msg ${@} ; exit 1 ; } } + +# from https://github.com/dinkel/docker-openldap/blob/master/entrypoint.sh: +# When not limiting the open file descritors limit, the memory consumption of +# slapd is absurdly high. See https://github.com/docker/docker/issues/8231 +ulimit -n 8192 + +msg "I: running slapd for initial setup..." +slapd -u openldap -g openldap -h ldapi:/// +assert "E: openldap died unexpectedly!" + +PIDFILE=$(ldapsearch -LLL -Y EXTERNAL -H ldapi:/// -b "cn=config" -s base \ + "" olcPidFile | grep olcPidFile | awk "{print $2}") +msg "I: slapd running with PID ${PIDFILE}" + +[[ -n "${OPENLDAP_ADMIN_PASSWORD}" ]] +assert "E: please set non-empty password in OPENLDAP_ADMIN_PASSWORD and retry." + +HASHED_PW=$(slappasswd -h {SSHA} -s "${OPENLDAP_ADMIN_PASSWORD}") +[[ -n "${HASHED_PW}" ]] +assert "E: password hash unexpectedly empty!" + +msg "I: Setting administrator password..." +ldapmodify -Y EXTERNAL -H ldapi:/// <- + docker exec -u openldap:openldap {{ openldap_container_name }} + mkdir "/var/lib/ldap/{{ ddn }}" + register: ret + failed_when: no + changed_when: "ret.rc == 0" + + - name: "Entrada en cn=config para {{ ddn }}" + register: "entry_add" + ldap: + dn: "olcDatabase=mdb,cn=config" + dn_relative: yes + filter: "(olcSuffix=\"{{ ddn }}\")" + objectClass: + - "olcDatabaseConfig" + - "olcMdbConfig" + attributes: + olcDbMaxSize: "1073741824" + olcSuffix: "{{ ddn }}" + olcDbDirectory: "/var/lib/ldap/{{ ddn }}" + olcRootDN: "{{ admindn }}" + olcRootPW: "{{ adminpw }}" + olcAccess: "{{ olcAccess }}" + olcDbCheckpoint: "512 30" + olcLastMod: "TRUE" + olcDbIndex: "{{ olcDbIndex }}" + olcLimits: "{{ domain.limits | default(openldap_default_db_limits) }}" + bind_dn: "cn=admin,cn=config" + bind_pw: "{{ openldap_admin_password }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + + - name: "Overlay memberof para {{ ddn }}" + when: "openldap_enable_memberof == True" + ldap: + dn: "olcOverlay=memberof,{{ entry_add.dn }}" + dn_relative: yes + objectClass: + - "olcOverlayConfig" + - "olcConfig" + - "olcMemberOf" + attributes: + olcMemberOfDangling: "ignore" + olcMemberOfRefInt: "FALSE" + olcMemberOfGroupOC: "groupOfNames" + olcMemberOfMemberAD: "member" + olcMemberOfMemberOfAD: "memberOf" + bind_dn: "cn=admin,cn=config" + bind_pw: "{{ openldap_admin_password }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + + - name: "Overlay syncprov para {{ ddn }}" + when: "openldap_provider == True" + ldap: + dn: "olcOverlay=syncprov,{{ entry_add.dn }}" + dn_relative: yes + objectClass: + - "olcOverlayConfig" + - "olcSyncProvConfig" + attributes: + olcSpNoPresent: "TRUE" + bind_dn: "cn=admin,cn=config" + bind_pw: "{{ openldap_admin_password }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + + - name: "Overlay accesslog para {{ ddn }}" + when: "openldap_provider == True" + ldap: + dn: "olcOverlay=accesslog,{{ entry_add.dn }}" + dn_relative: yes + objectClass: + - "olcOverlayConfig" + - "olcAccessLogConfig" + attributes: + olcAccessLogDB: "cn=accesslog" + olcAccessLogOps: "writes" + olcAccessLogPurge: "07+00:00 01+00:00" + olcAccessLogSuccess: "TRUE" + bind_dn: "cn=admin,cn=config" + bind_pw: "{{ openldap_admin_password }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + + # EL ORDEN EN LAS SIGUIENTES 2 es importante!!! + # solo se soporta UN provider + - name: "Configurar cliente de replicacion (1: entrada olcSyncRepl)" + when: + - "openldap_consumer == True" + - "openldap_provider_host | bool == True" + ldap_attr: + dn: "{{ entry_add.dn }}" + name: "olcSyncRepl" + state: "exact" + values: >- + {0}rid=3 + provider=ldap://{{ openldap_provider_host }} + bindmethod=simple + binddn="{{ openldap_replicator_dn }}" + credentials="{{ openldap_replicator_password }}" + searchbase="{{ openldap_replicator_base }}" + logbase="cn=accesslog" + logfilter="(&(objectClass=auditWriteObject)(reqResult=0))" + schemachecking=on + type=refreshAndPersist + retry="60 10 300 +" + interval=00:00:01:00 syncdata=accesslog + bind_dn: "cn=admin,cn=config" + bind_pw: "{{ openldap_admin_password }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + + - name: "Configurar cliente de replicacion (2: entrada olcUpdateRef)" + when: + - "openldap_consumer == True" + - "openldap_provider_host | bool == True" + ldap_attr: + dn: "{{ entry_add.dn }}" + name: "olcUpdateRef" + state: "exact" + values: "ldap://{{ openldap_provider_host }}" + bind_dn: "cn=admin,cn=config" + bind_pw: "{{ openldap_admin_password }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + + - name: "Desconfigurar cliente de replicacion (2: entrada olcUpdateRef)" + when: "openldap_consumer == False" + ldap_attr: + dn: "{{ entry_add.dn }}" + name: "olcUpdateRef" + state: "exact" + values: [] + bind_dn: "cn=admin,cn=config" + bind_pw: "{{ openldap_admin_password }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + + - name: "Desconfigurar cliente de replicacion (1: entrada olcSyncRepl)" + when: "openldap_consumer == False" + ldap_attr: + dn: "{{ entry_add.dn }}" + name: "olcSyncRepl" + state: "exact" + values: [] + bind_dn: "cn=admin,cn=config" + bind_pw: "{{ openldap_admin_password }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + + # fin del bloque ------------------------------- + +# parte 3: agregar entrada en el DIT y crear usuario admin, replicator, OUs --------------- + +- when: + - "openldap_create_dit_entries == True" + - "openldap_consumer == False" + block: + + - name: "(aux) credenciales para modificar entradas en {{ super.dn }}" + set_fact: + rootdn: "cn={{ super.admincn | default('admin') }},{{ super.dn }}" + rootpw: "{{ super.adminpw | default(openldap_admmin_password|default('password')) }}" + + - name: "Entrada para organización {{ ddn }}" + ldap: + dn: "{{ ddn }}" + objectClass: + - "dcObject" + - "organization" + - "top" + attributes: + o: "{{ domain.name }}" + bind_dn: "{{ rootdn }}" + bind_pw: "{{ rootpw }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + + - name: "Usuario admin para {{ ddn }}" + ldap: + dn: "{{ admindn }}" + objectClass: + - "organizationalRole" + - "simpleSecurityObject" + attributes: + description: "LDAP Administrator role for domain {{ domain.name }}" + userPassword: "{{ adminpw }}" + bind_dn: "{{ rootdn }}" + bind_pw: "{{ rootpw }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + + - name: "Usuario de replicacion para {{ ddn }}" + when: + - "super.dn == ddn" + - "ddn in openldap_replicator_dn" + ldap: + dn: "{{ openldap_replicator_dn }}" + objectClass: + - "organizationalRole" + - "simpleSecurityObject" + attributes: + description: "LDAP Replication role for domain {{ domain.name }}" + userPassword: "{{ openldap_replicator_password }}" + bind_dn: "{{ rootdn }}" + bind_pw: "{{ rootpw }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + + - name: "OUs para {{ ddn }}" + ldap: + dn: "ou={{ item }},{{ ddn }}" + objectClass: + - "organizationalUnit" + - "top" + attributes: + ou: "{{ item }}" + bind_dn: "{{ rootdn }}" + bind_pw: "{{ rootpw }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + loop: "{{ domain.ou | default(openldap_default_domain_ous) }}" + + - set_fact: + ldap_base_dn: "{{ lookup('vars', 'ldap_base_dn', default=ddn) }}" + ldap_admin_dn: "{{ lookup('vars', 'ldap_admin_dn', default=rootdn) }}" + ldap_admin_password: "{{ lookup('vars', 'ldap_admin_password', default=rootpw) }}" + + # fin del bloque ------------------------------- diff --git a/tasks/main.yml b/tasks/main.yml new file mode 100644 index 0000000..8b53d44 --- /dev/null +++ b/tasks/main.yml @@ -0,0 +1,52 @@ +--- +# Playbook for setting up a Docker container with openLDAP. +# A port binding to the Docker host is required for setting +# up domains and replication. + +- name: "Create directory for building image" + file: + path: "/tmp/build.openldap-image" + state: "directory" + +- name: "Copy required files" + copy: + src: "{{ item }}" + dest: "/tmp/build.openldap-image/" + loop: + - "Dockerfile" + - "entrypoint.sh" + +- name: "Build openldap image" + docker_image: + path: "/tmp/build.openldap-image" + name: "{{ openldap_image_name }}" + +- name: "Start openldap container" + docker_container: + image: "{{ openldap_image_name }}" + name: "{{ openldap_container_name }}" + volumes: + - "{{ openldap_volume_config }}:/etc/ldap" + - "{{ openldap_volume_data }}:/var/lib/ldap" + - "{{ openldap_volume_backup }}:/var/backups/ldap" + env: + OPENLDAP_ADMIN_PASSWORD: "{{ openldap_admin_password }}" + OPENLDAP_SCHEMAS: "{{ openldap_schemas | join (' ') }}" + OPENLDAP_ENABLE_MEMBEROF: "{{ 'true' if openldap_enable_module_memberof else 'false' }}" + + networks: + - name: "{{ docker_network_name }}" + ports: + - "{{ openldap_bind_host }}:{{ openldap_bind_port }}:389" + +- include_tasks: "provider.yml" + when: "openldap_provider == True" + +- include_tasks: "domain.yml" + loop: "{{ openldap_domains }}" + loop_control: + loop_var: "domain" + +- set_fact: + ldap_uri: "{{ lookup( 'vars', 'ldap_uri', + default='ldap://'+openldap_container_name+':389') }}" diff --git a/tasks/provider.yml b/tasks/provider.yml new file mode 100644 index 0000000..1f70b40 --- /dev/null +++ b/tasks/provider.yml @@ -0,0 +1,82 @@ +--- +# En este archivo se configura el provider en un esquema +# de replicación delta-syncrepl. +# Ver https://openldap.org/doc/admin24/replication.html#Delta-syncrepl + +- name: "Habilitar módulos syncprov y accesslog" + ldap_attr: + dn: "cn=module{0},cn=config" + name: "olcModuleLoad" + values: >- + [ {% if openldap_enable_memberof %} + "{2}syncprov", "{3}accesslog" {% else %} + "{1}syncprov", "{2}accesslog" {% endif %} ] + bind_dn: "cn=admin,cn=config" + bind_pw: "{{ openldap_admin_password }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + +- name: "Crear directorio para db accesslog" + file: + path: "{{ openldap_accesslog_dir }}" + state: "directory" + owner: "openldap" + group: "openldap" + +- name: "Configurar base cn=accesslog" + ldap: + dn: "olcDatabase=mdb,cn=config" + dn_relative: yes + filter: "(olcSuffix=cn=accesslog)" + objectClass: + - "olcDatabaseConfig" + - "olcMdbConfig" + attributes: + olcRootDN: "{{ openldap_accesslog_admin_dn }}" + olcDbMaxSize: "8589934592" + olcSuffix: "cn=accesslog" + olcDbDirectory: "{{ openldap_accesslog_dir }}" + olcAccess: + - "{0}to * by dn=\"{{ openldap_replicator_dn }}\" read" + olcLimits: + - >- + {0}dn.exact="{{ openldap_replicator_dn }}" + time.soft=unlimited + time.hard=unlimited + size.soft=unlimited + size.hard=unlimited + - >- + {1}dn.exact="{{ openldap_accesslog_admin_dn }}" + time.soft=unlimited + time.hard=unlimited + size.soft=unlimited + size.hard=unlimited + bind_dn: "cn=admin,cn=config" + bind_pw: "{{ openldap_admin_password }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + register: accesslog_entry + +- name: "Configurar base olcOverlay=syncprov,{{ accesslog_entry.dn }}" + ldap: + dn: "olcOverlay=syncprov,{{ accesslog_entry.dn }}" + dn_relative: yes + objectClass: + - "olcOverlayConfig" + - "olcSyncProvConfig" + attributes: + olcSpNoPresent: "TRUE" + olcSpReloadHint: "TRUE" + bind_dn: "cn=admin,cn=config" + bind_pw: "{{ openldap_admin_password }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}" + +# Esto se debe agregar luego del overlay, por eso va aparte +- name: "Configurar propiedad olcDbIndex de {{ accesslog_entry.dn }}" + ldap_attr: + dn: "{{ accesslog_entry.dn }}" + name: "olcDbIndex" + values: + - "default eq" + - "entryCSN,objectClass,reqEnd,reqResult,reqStart" + bind_dn: "cn=admin,cn=config" + bind_pw: "{{ openldap_admin_password }}" + server_uri: "ldap://localhost:{{ openldap_bind_port }}"