From 7acb4949a171bb439476e629eb9083d650200d75 Mon Sep 17 00:00:00 2001 From: Frank Brehm Date: Tue, 28 Jun 2022 17:47:42 +0200 Subject: [PATCH] Start LDAP querying for authkeys of root --- lib/cr_vmware_tpl/__init__.py | 3 +- lib/cr_vmware_tpl/app.py | 5 +- lib/cr_vmware_tpl/config.py | 420 +++++++++++++++++++++++++++++++++- lib/cr_vmware_tpl/handler.py | 86 ++++++- requirements.txt | 1 + 5 files changed, 504 insertions(+), 11 deletions(-) diff --git a/lib/cr_vmware_tpl/__init__.py b/lib/cr_vmware_tpl/__init__.py index 1b8d056..d35d395 100644 --- a/lib/cr_vmware_tpl/__init__.py +++ b/lib/cr_vmware_tpl/__init__.py @@ -3,10 +3,11 @@ import time -__version__ = '2.5.1' +__version__ = '2.6.0' DEFAULT_CONFIG_DIR = 'pixelpark' DEFAULT_DISTRO_ARCH = 'x86_64' +MAX_PORT_NUMBER = (2 ** 16) - 1 # ------------------------------------------------------------------------- def print_section_start(name, header=None, collapsed=False): diff --git a/lib/cr_vmware_tpl/app.py b/lib/cr_vmware_tpl/app.py index 8696795..966bbc0 100644 --- a/lib/cr_vmware_tpl/app.py +++ b/lib/cr_vmware_tpl/app.py @@ -38,7 +38,7 @@ from .xlate import __base_dir__ as __xlate_base_dir__ from .xlate import __mo_file__ as __xlate_mo_file__ from .xlate import XLATOR, LOCALE_DIR, DOMAIN -__version__ = '1.5.0' +__version__ = '1.5.1' LOG = logging.getLogger(__name__) _ = XLATOR.gettext @@ -377,10 +377,13 @@ class CrTplApplication(FbConfigApplication): try: ret = self.handler() + self.handler = None self.exit(ret) except ExpectedHandlerError as e: self.handle_error(str(e), _("Temporary VM")) self.exit(5) + finally: + self.handler = None # ============================================================================= diff --git a/lib/cr_vmware_tpl/config.py b/lib/cr_vmware_tpl/config.py index 7654c2e..b1869f6 100644 --- a/lib/cr_vmware_tpl/config.py +++ b/lib/cr_vmware_tpl/config.py @@ -18,8 +18,8 @@ import crypt from pathlib import Path # Own modules -from fb_tools.common import is_sequence, pp -from fb_tools.obj import FbGenericBaseObject +from fb_tools.common import is_sequence, pp, to_bool +from fb_tools.obj import FbGenericBaseObject, FbBaseObject from fb_tools.collections import CIStringSet from fb_tools.multi_config import MultiConfigError, BaseMultiConfig from fb_tools.multi_config import DEFAULT_ENCODING @@ -27,16 +27,25 @@ from fb_tools.xlate import format_list from fb_vmware.config import VSPhereConfigInfo -from . import DEFAULT_CONFIG_DIR, DEFAULT_DISTRO_ARCH +from . import DEFAULT_CONFIG_DIR, DEFAULT_DISTRO_ARCH, MAX_PORT_NUMBER from .xlate import XLATOR -__version__ = '1.9.5' +__version__ = '2.0.0' LOG = logging.getLogger(__name__) _ = XLATOR.gettext ngettext = XLATOR.ngettext +DEFAULT_PORT_LDAP = 389 +DEFAULT_PORT_LDAPS = 636 +DEFAULT_TIMEOUT = 20 +MAX_TIMEOUT = 3600 +DEFAULT_ADMIN_FILTER = ( + '(&(inetuserstatus=active)(mailuserstatus=active)(objectclass=pppixelaccount)(mail=*)' + '(memberOf=cn=Administratoren Pixelpark Berlin,ou=Groups,o=Pixelpark,o=isp))' +) + # ============================================================================= class CrTplConfigError(MultiConfigError): @@ -46,6 +55,295 @@ class CrTplConfigError(MultiConfigError): pass +# ============================================================================= +class LdapConnectionInfo(FbBaseObject): + """Encapsulating all necessary data to connect to a LDAP server.""" + + re_host_key = re.compile(r'^\s*(?:host|server)\s*$', re.IGNORECASE) + re_ldaps_key = re.compile(r'^\s*(?:use[_-]?)?(?:ldaps|ssl)\s*$', re.IGNORECASE) + re_port_key = re.compile(r'^\s*port\s*$', re.IGNORECASE) + re_base_dn_key = re.compile(r'^\s*base[_-]*dn\s*$', re.IGNORECASE) + re_bind_dn_key = re.compile(r'^\s*bind[_-]*dn\s*$', re.IGNORECASE) + re_bind_pw_key = re.compile(r'^\s*bind[_-]*pw\s*$', re.IGNORECASE) + re_admin_filter_key = re.compile(r'^\s*(?:admin[_-]?)?filter\s*$', re.IGNORECASE) + + # ------------------------------------------------------------------------- + def __init__( + self, appname=None, verbose=0, version=__version__, base_dir=None, + host=None, use_ldaps=False, port=DEFAULT_PORT_LDAP, base_dn=None, + bind_dn=None, bind_pw=None, admin_filter=None, initialized=False): + + self._host = None + self._use_ldaps = False + self._port = DEFAULT_PORT_LDAP + self._base_dn = None + self._bind_dn = None + self._bind_pw = None + self._admin_filter = DEFAULT_ADMIN_FILTER + + super(LdapConnectionInfo, self).__init__( + appname=appname, verbose=verbose, version=version, base_dir=base_dir, + initialized=False) + + self.host = host + self.use_ldaps = use_ldaps + self.port = port + if base_dn: + self.base_dn = base_dn + self.bind_dn = bind_dn + self.bind_pw = bind_pw + if admin_filter: + self.admin_filter = admin_filter + + if initialized: + self.initialized = True + + # ------------------------------------------------------------------------- + def as_dict(self, short=True): + """ + Transforms the elements of the object into a dict + + @param short: don't include local properties in resulting dict. + @type short: bool + + @return: structure as dict + @rtype: dict + """ + + res = super(LdapConnectionInfo, self).as_dict(short=short) + + res['host'] = self.host + res['use_ldaps'] = self.use_ldaps + res['port'] = self.port + res['base_dn'] = self.base_dn + res['bind_dn'] = self.bind_dn + res['bind_pw'] = None + res['schema'] = self.schema + res['url'] = self.url + res['admin_filter'] = self.admin_filter + + if self.bind_pw: + if self.verbose > 4: + res['bind_pw'] = self.bind_pw + else: + res['bind_pw'] = '******' + + return res + + # ----------------------------------------------------------- + @property + def host(self): + """The host name (or IP address) of the LDAP server.""" + return self._host + + @host.setter + def host(self, value): + if value is None or str(value).strip() == '': + self._host = None + return + self._host = str(value).strip().lower() + + # ----------------------------------------------------------- + @property + def use_ldaps(self): + """Should there be used LDAPS for communicating with the LDAP server?""" + return self._use_ldaps + + @use_ldaps.setter + def use_ldaps(self, value): + self._use_ldaps = to_bool(value) + + # ----------------------------------------------------------- + @property + def port(self): + "The TCP port number of the LDAP server." + return self._port + + @port.setter + def port(self, value): + v = int(value) + if v < 1 or v > MAX_PORT_NUMBER: + raise CrTplConfigError(_("Invalid port {!r} for LDAP server given.").format(value)) + self._port = v + + # ----------------------------------------------------------- + @property + def base_dn(self): + """The DN used to connect to the LDAP server, anonymous bind is used, if + this DN is empty or None.""" + return self._base_dn + + @base_dn.setter + def base_dn(self, value): + if value is None or str(value).strip() == '': + msg = _("An empty Base DN for LDAP searches is not allowed.") + raise CrTplConfigError(msg) + self._base_dn = str(value).strip() + + # ----------------------------------------------------------- + @property + def bind_dn(self): + """The DN used to connect to the LDAP server, anonymous bind is used, if + this DN is empty or None.""" + return self._bind_dn + + @bind_dn.setter + def bind_dn(self, value): + if value is None or str(value).strip() == '': + self._bind_dn = None + return + self._bind_dn = str(value).strip() + + # ----------------------------------------------------------- + @property + def bind_pw(self): + """The password of the DN used to connect to the LDAP server.""" + return self._bind_pw + + @bind_pw.setter + def bind_pw(self, value): + if value is None or str(value).strip() == '': + self._bind_pw = None + return + self._bind_pw = str(value).strip() + + # ----------------------------------------------------------- + @property + def admin_filter(self): + """The LDAP filter to get the list of administrators from LDAP.""" + return self._admin_filter + + @admin_filter.setter + def admin_filter(self, value): + if value is None or str(value).strip() == '': + self._admin_filter = None + return + self._admin_filter = str(value).strip() + + # ----------------------------------------------------------- + @property + def schema(self): + """The schema as part of the URL to connect to the LDAP server.""" + if self.use_ldaps: + return 'ldaps' + return 'ldap' + + # ----------------------------------------------------------- + @property + def url(self): + """The URL, which ca be used to connect to the LDAP server.""" + if not self.host: + return None + + port = '' + if self.use_ldaps: + if self.port != DEFAULT_PORT_LDAPS: + port = ':{}'.format(self.port) + else: + if self.port != DEFAULT_PORT_LDAP: + port = ':{}'.format(self.port) + + return '{s}://{h}{p}'.format(s=self.schema, h=self.host, p=port) + + # ------------------------------------------------------------------------- + def __repr__(self): + """Typecasting into a string for reproduction.""" + + out = "<%s(" % (self.__class__.__name__) + + fields = [] + fields.append("appname={!r}".format(self.appname)) + fields.append("host={!r}".format(self.host)) + fields.append("use_ldaps={!r}".format(self.use_ldaps)) + fields.append("port={!r}".format(self.port)) + fields.append("base_dn={!r}".format(self.base_dn)) + fields.append("bind_dn={!r}".format(self.bind_dn)) + fields.append("bind_pw={!r}".format(self.bind_pw)) + fields.append("admin_filter={!r}".format(self.admin_filter)) + fields.append("initialized={!r}".format(self.initialized)) + + out += ", ".join(fields) + ")>" + return out + + # ------------------------------------------------------------------------- + def __copy__(self): + + new = self.__class__( + appname=self.appname, verbose=self.verbose, base_dir=self.base_dir, host=self.host, + use_ldaps=self.use_ldaps, port=self.port, base_dn=self.base_dn, bind_dn=self.bind_dn, + bind_pw=self.bind_pw, admin_filter=self.admin_filter, initialized=self.initialized) + + return new + + # ------------------------------------------------------------------------- + @classmethod + def init_from_config(cls, name, data, appname=None, verbose=0, base_dir=None): + + new = cls(appname=appname, verbose=verbose, base_dir=base_dir) + + s_name = "ldap:" + name + msg_invalid = _("Invalid value {val!r} in section {sec!r} for a LDAP {what}.") + + for key in data.keys(): + value = data[key] + + if cls.re_host_key.match(key): + if value.strip(): + new.host = value + else: + msg = msg_invalid.format(val=value, sec=s_name, what='host') + LOG.error(msg) + continue + + if cls.re_ldaps_key.match(key): + new.use_ldaps = value + continue + + if cls.re_port_key.match(key): + port = DEFAULT_PORT_LDAP + try: + port = int(value) + except (ValueError, TypeError) as e: + msg = msg_invalid.format(val=value, sec=s_name, what='port') + msg += ' ' + str(e) + LOG.error(msg) + continue + if port <= 0 or port > MAX_PORT_NUMBER: + msg = msg_invalid.format(val=value, sec=s_name, what='port') + LOG.error(msg) + continue + new.port = port + continue + + if cls.re_base_dn_key.match(key): + if value.strip(): + new.base_dn = value + else: + msg = msg_invalid.format(val=value, sec=s_name, what='base_dn') + LOG.error(msg) + continue + + if cls.re_bind_dn_key.match(key): + new.bind_dn = value + continue + + if cls.re_bind_pw_key.match(key): + new.bind_pw = value + continue + + if cls.re_admin_filter_key.match(key): + new.admin_filter = value + continue + + msg = _("Unknown LDAP configuration key {key} found in section {sec!r}.").format( + key=key, sec=s_name) + LOG.error(msg) + + new.initialized = True + + return new + + # ============================================================================= class CobblerDistroInfo(FbGenericBaseObject): """Class for encapsulation all necessary data of a Repo definition in Cobbler.""" @@ -358,6 +656,40 @@ class CobblerDistroInfo(FbGenericBaseObject): return new +# ============================================================================= +class LdapConnectionDict(dict, FbGenericBaseObject): + """A dictionary containing LdapConnectionInfo as values and their names as keys.""" + + # ------------------------------------------------------------------------- + def as_dict(self, short=True): + """ + Transforms the elements of the object into a dict + + @param short: don't include local properties in resulting dict. + @type short: bool + + @return: structure as dict + @rtype: dict + """ + + res = super(LdapConnectionDict, self).as_dict(short=short) + + for key in self.keys(): + res[key] = self[key].as_dict(short=short) + + return res + + # ------------------------------------------------------------------------- + def __copy__(self): + + new = self.__class__() + + for key in self.keys(): + new[key] = copy.copy(self[key]) + + return new + + # ============================================================================= class CrTplConfiguration(BaseMultiConfig): """ @@ -440,6 +772,14 @@ class CrTplConfiguration(BaseMultiConfig): default_swap_size_mb = 512 + default_ldap_server = 'prd-ds.pixelpark.com' + use_ssl_on_default = True + default_ldap_port = DEFAULT_PORT_LDAPS + default_base_dn = 'o=isp' + default_bind_dn = 'uid=readonly,ou=People,o=isp' + + re_ldap_section_w_name = re.compile(r'^\s*ldap\s*:\s*(\S+)') + # ------------------------------------------------------------------------- def __init__( self, appname=None, verbose=0, version=__version__, base_dir=None, @@ -459,6 +799,8 @@ class CrTplConfiguration(BaseMultiConfig): add_stems.append('mail') if 'cobbler-repos' not in add_stems: add_stems.append('cobbler-distros') + if 'ldap' not in add_stems: + add_stems.append('ldap') self.os_id = self.default_os_id @@ -520,6 +862,8 @@ class CrTplConfiguration(BaseMultiConfig): config_dir = DEFAULT_CONFIG_DIR LOG.debug("Config dir: {!r}.".format(config_dir)) + self.ldap_timeout = DEFAULT_TIMEOUT + super(CrTplConfiguration, self).__init__( appname=appname, verbose=verbose, version=version, base_dir=base_dir, append_appname_to_stems=append_appname_to_stems, config_dir=config_dir, @@ -536,6 +880,16 @@ class CrTplConfiguration(BaseMultiConfig): self.private_ssh_key = str(self.base_dir.joinpath('keys', self.ssh_privkey)) + self.ldap_connection = LdapConnectionDict() + + default_connection = LdapConnectionInfo( + appname=self.appname, verbose=self.verbose, base_dir=self.base_dir, + host=self.default_ldap_server, use_ldaps=self.use_ssl_on_default, + port=self.default_ldap_port, base_dn=self.default_base_dn, + bind_dn=self.default_bind_dn, initialized=False) + + self.ldap_connection['default'] = default_connection + if initialized: self.initialized = True @@ -740,35 +1094,87 @@ class CrTplConfiguration(BaseMultiConfig): re_cobbler_distros = re.compile(r'^\s*cobbler[_-]?distros\s*$', re.IGNORECASE) re_cobbler_repos = re.compile(r'^\s*cobbler[_-]?repos\s*$', re.IGNORECASE) + sn = section_name.lower() + section = self.cfg[section_name] + LOG.debug(_("Evaluating section {!r} ...").format(section_name)) + if self.verbose > 2: + LOG.debug(_("Content of section:") + '\n' + pp(section)) super(CrTplConfiguration, self).eval_section(section_name) - sn = section_name.lower() - section = self.cfg[section_name] - if sn == 'vsphere': self._eval_config_vsphere(section_name, section) return + if sn == 'template': self._eval_config_template(section_name, section) return + if sn == 'timeouts': self._eval_config_timeouts(section_name, section) return + if sn == 'cobbler': self._eval_config_cobbler(section_name, section) return + if re_cobbler_distros.match(section_name): self._eval_cobbler_distros(section_name, section) return + if re_cobbler_repos.match(section_name): self._eval_cobbler_repos(section_name, section) return + if sn == 'ldap': + for key in section.keys(): + sub = section[key] + if key.lower().strip() == 'timeout': + self._eval_ldap_timeout(sub) + continue + self._eval_ldap_connection(key, sub) + return + + match = self.re_ldap_section_w_name.match(sn) + if match: + connection_name = match.group(1) + self._eval_ldap_connection(connection_name, section) + return + if self.verbose > 1: LOG.debug(_("Unhandled configuration section {!r}.").format(section_name)) + # ------------------------------------------------------------------------- + def _eval_ldap_timeout(self, value): + + timeout = DEFAULT_TIMEOUT + msg_invalid = _("Value {!r} for a timeout is invalid.") + + try: + timeout = int(value) + except (ValueError, TypeError) as e: + msg = msg_invalid.format(value) + msg += ': ' + str(e) + LOG.error(msg) + return + if timeout <= 0 or timeout > MAX_TIMEOUT: + msg = msg_invalid.format(value) + LOG.error(msg) + return + + self.ldap_timeout = timeout + + # ------------------------------------------------------------------------- + def _eval_ldap_connection(self, connection_name, section): + + connection = LdapConnectionInfo.init_from_config( + connection_name, section, + appname=self.appname, verbose=self.verbose, base_dir=self.base_dir, + ) + + self.ldap_connection[connection_name] = connection + # ------------------------------------------------------------------------- def _eval_config_vsphere(self, section_name, section): diff --git a/lib/cr_vmware_tpl/handler.py b/lib/cr_vmware_tpl/handler.py index 5a6e3f2..155f78d 100644 --- a/lib/cr_vmware_tpl/handler.py +++ b/lib/cr_vmware_tpl/handler.py @@ -24,6 +24,10 @@ import paramiko from pyVmomi import vim +import ldap3 +from ldap3.core.exceptions import LDAPInvalidDnError, LDAPInvalidValueError +from ldap3.core.exceptions import LDAPException, LDAPBindError + # Own modules from fb_tools.common import pp, to_str @@ -43,13 +47,13 @@ from fb_vmware.datastore import VsphereDatastore from . import print_section_start, print_section_end -from .config import CrTplConfiguration +from .config import CrTplConfiguration, DEFAULT_PORT_LDAP, DEFAULT_PORT_LDAPS from .cobbler import Cobbler from .xlate import XLATOR -__version__ = '2.1.3' +__version__ = '2.2.0' LOG = logging.getLogger(__name__) TZ = pytz.timezone('Europe/Berlin') @@ -149,6 +153,8 @@ class CrTplHandler(BaseHandler): self.abort = False self.postinstall_errors = None self.cobbler = None + self.ldap = None + self.ldap_server = None self.vsphere = VsphereConnection( self.cfg.vsphere_info, cluster=self.cfg.vsphere_cluster, @@ -222,6 +228,71 @@ class CrTplHandler(BaseHandler): return res + # ------------------------------------------------------------------------- + def connect_ldap(self): + + ldap_config = self.cfg.ldap_connection['default'] + + server_opts = {} + if ldap_config.use_ldaps: + server_opts['use_ssl'] = True + if ldap_config.port != DEFAULT_PORT_LDAPS: + server_opts['port'] = ldap_config.port + else: + server_opts['use_ssl'] = False + if ldap_config.port != DEFAULT_PORT_LDAP: + server_opts['port'] = ldap_config.port + + server_opts['get_info'] = ldap3.DSA + server_opts['mode'] = ldap3.IP_V4_PREFERRED + server_opts['connect_timeout'] = self.cfg.ldap_timeout + + LOG.info(_("Connecting to LDAP server {!r} ...").format(ldap_config.url)) + + if self.verbose > 1: + msg = _("Connect options to LDAP server {!r}:").format(ldap_config.url) + msg += '\n' + pp(server_opts) + LOG.debug(msg) + + self.ldap_server = ldap3.Server(ldap_config.host, **server_opts) + + if self.verbose > 2: + LOG.debug("LDAP server {s}: {re}".format( + s=ldap_config.host, re=repr(self.ldap_server))) + + self.ldap = ldap3.Connection( + self.ldap_server, ldap_config.bind_dn, ldap_config.bind_pw, + client_strategy=ldap3.SAFE_SYNC, auto_bind=True) + + if self.verbose > 2: + msg = _("Info about LDAP server {}:").format(ldap_config.url) + msg += '\n' + repr(self.ldap_server.info) + LOG.debug(msg) + + # ------------------------------------------------------------------------- + def disconnect_ldap(self): + + if 'default' in self.cfg.ldap_connection: + ldap_config = self.cfg.ldap_connection['default'] + ldap_server = ldap_config.url + else: + ldap_server = 'unknown' + + if self.ldap: + LOG.info(_("Unbinding from LDAP server {} ...").format(ldap_server)) + self.ldap.unbind() + self.ldap = None + + if self.ldap_server: + LOG.info(_("Disconnecting from LDAP server {} ...").format(ldap_server)) + self.ldap_server = None + + # ------------------------------------------------------------------------- + def __del__(self): + """Destructor.""" + + self.disconnect_ldap() + # ------------------------------------------------------------------------- def __call__(self): """Executing the underlying action.""" @@ -267,6 +338,7 @@ class CrTplHandler(BaseHandler): self.cobbler.get_cobbler_version() self.check_for_cobbler_distro() self.cobbler.ensure_profile_ks() + self.create_root_authkeys() return 0 self.cobbler.ensure_profile() self.cobbler.ensure_root_authkeys() @@ -1183,6 +1255,16 @@ class CrTplHandler(BaseHandler): LOG.debug(_("Object {!r} is now a VMWare template.").format(self.cfg.template_name)) print_section_end('rename_and_change_vm') + # ------------------------------------------------------------------------- + def create_root_authkeys(self): + + LOG.info(_("Creating authorized keys of root from LDAP ...")) + + try: + self.connect_ldap() + finally: + self.disconnect_ldap() + # ============================================================================= if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index dda7db9..718f7c9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ PyYAML toml hjson jinja2 +ldap3 fb_logging fb_tools fb_vmware -- 2.39.5