From 624e0d34355b947052b996ea23c503ef2f7cd349 Mon Sep 17 00:00:00 2001 From: Thomas Woerner <twoerner@redhat.com> Date: Mon, 21 Nov 2022 14:44:09 +0100 Subject: [PATCH] ipaclient: No kinit on controller for deployment using OTP The generation of the OTP for client deployment is now completely happening on the first of the given or detected servers with delegate_to. The module ipaclient_get_otp has been replaced by a new module using code from ipahost module and module_utils ansible_freeipa_module. The action plugin ipaclient_get_otp has been removed and with this also ipaclient_get_facts. If an admin keytab is used instead of an admin password, it is copied to the server as a temporary file to enable the OTP generation. The temporary file is removed again after using the ipaclient_get_otp module. The utils script build-galaxy-release.sh has been updated to not copy the ipaclient action plugin to the global plugins folder of the collection. This change is import for the use of the ipaclient role with AAP as only the base environment is sufficient now. The ipaclient README and also the global README have been updated as kinit is not needed anymore on the controller for OTP. Fixes #903 (Allow the use of principals other than admin when using ipaadmin_keytab) --- README.md | 3 +- roles/ipaclient/README.md | 3 +- .../action_plugins/ipaclient_get_otp.py | 247 --------- .../ipaclient/library/ipaclient_get_facts.py | 282 ----------- roles/ipaclient/library/ipaclient_get_otp.py | 477 ++++++++---------- roles/ipaclient/tasks/install.yml | 57 ++- utils/build-galaxy-release.sh | 10 +- 7 files changed, 255 insertions(+), 824 deletions(-) delete mode 100644 roles/ipaclient/action_plugins/ipaclient_get_otp.py delete mode 100644 roles/ipaclient/library/ipaclient_get_facts.py diff --git a/README.md b/README.md index f3ff4aaa..34d8e69d 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,6 @@ Requirements **Controller** * Ansible version: 2.8+ (ansible-freeipa is an Ansible Collection) -* /usr/bin/kinit is required on the controller if a one time password (OTP) is used **Node** * Supported FreeIPA version (see above) @@ -289,7 +288,7 @@ ipaserver_domain=test.local ipaserver_realm=TEST.LOCAL ``` -For enhanced security it is possible to use a auto-generated one-time-password (OTP). This will be generated on the controller using the (first) server. +For enhanced security it is possible to use a auto-generated one-time-password (OTP). This will be generated on the (first) server. To enable the generation of the one-time-password: ```yaml diff --git a/roles/ipaclient/README.md b/roles/ipaclient/README.md index 4804de73..43a68df7 100644 --- a/roles/ipaclient/README.md +++ b/roles/ipaclient/README.md @@ -32,7 +32,6 @@ Requirements **Controller** * Ansible version: 2.8+ -* /usr/bin/kinit is required on the controller if a one time password (OTP) is used **Node** * Supported FreeIPA version (see above) @@ -172,7 +171,7 @@ Server Variables Variable | Description | Required -------- | ----------- | -------- `ipaservers` | This group is a list of the IPA server full qualified host names. In a topology with a chain of servers and replicas, it is important to use the right server or replica as the server for the client. If there is a need to overwrite the setting for a client in the `ipaclients` group, please use the list `ipaclient_servers` explained below. If no `ipaservers` group is defined than the installation preparation step will try to use DNS autodiscovery to identify the the IPA server using DNS txt records. | mostly -`ipaadmin_keytab` | The string variable enables the use of an admin keytab as an alternative authentication method. The variable needs to contain the local path to the keytab file. If `ipaadmin_keytab` is used, then `ipaadmin_password` does not need to be set. If `ipaadmin_keytab` is used with `ipaclient_use_otp: yes` then the keytab needs to be available on the controller, else on the client node. The use of full path names is recommended. | no +`ipaadmin_keytab` | The string variable enables the use of an admin keytab as an alternative authentication method. The variable needs to contain the local path to the keytab file. If `ipaadmin_keytab` is used, then `ipaadmin_password` does not need to be set. If `ipaadmin_keytab` is used with `ipaclient_use_otp: yes` then the keytab needs to be available on the controller, else on the client node. The use of full path names is recommended. | no `ipaadmin_principal` | The string variable only needs to be set if the name of the Kerberos admin principal is not "admin". If `ipaadmin_principal` is not set it will be set internally to "admin". | no `ipaadmin_password` | The string variable contains the Kerberos password of the Kerberos admin principal. If `ipaadmin_keytab` is used, then `ipaadmin_password` does not need to be set. | mostly diff --git a/roles/ipaclient/action_plugins/ipaclient_get_otp.py b/roles/ipaclient/action_plugins/ipaclient_get_otp.py deleted file mode 100644 index d6c429fd..00000000 --- a/roles/ipaclient/action_plugins/ipaclient_get_otp.py +++ /dev/null @@ -1,247 +0,0 @@ -# Authors: -# Florence Blanc-Renaud <frenaud@redhat.com> -# -# Copyright (C) 2017 Red Hat -# see file 'COPYING' for use and warranty information -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -from __future__ import (absolute_import, division, print_function) - -__metaclass__ = type - -import os -import shutil -import subprocess -import tempfile -from jinja2 import Template - -from ansible.errors import AnsibleError -from ansible.module_utils._text import to_native -from ansible.plugins.action import ActionBase - - -def run_cmd(args, stdin=None): - """Execute an external command.""" - p_in = None - p_out = subprocess.PIPE - p_err = subprocess.PIPE - - if stdin: - p_in = subprocess.PIPE - - # pylint: disable=invalid-name - with subprocess.Popen( - args, stdin=p_in, stdout=p_out, stderr=p_err, close_fds=True - ) as p: - __temp, stderr = p.communicate(stdin) - - if p.returncode != 0: - raise RuntimeError(stderr) - - -def kinit_password(principal, password, ccache_name, config): - """ - Perform kinit using principal/password. - - It uses the specified config file to kinit and stores the TGT - in ccache_name. - """ - args = ["/usr/bin/kinit", principal, '-c', ccache_name] - old_config = os.environ.get('KRB5_CONFIG') - os.environ['KRB5_CONFIG'] = config - - try: - return run_cmd(args, stdin=password.encode()) - finally: - if old_config is not None: - os.environ['KRB5_CONFIG'] = old_config - else: - os.environ.pop('KRB5_CONFIG', None) - - -def kinit_keytab(principal, keytab, ccache_name, config): - """ - Perform kinit using principal/keytab. - - It uses the specified config file to kinit and stores the TGT - in ccache_name. - """ - args = ["/usr/bin/kinit", "-kt", keytab, "-c", ccache_name, principal] - old_config = os.environ.get('KRB5_CONFIG') - os.environ["KRB5_CONFIG"] = config - - try: - return run_cmd(args) - finally: - if old_config is not None: - os.environ["KRB5_CONFIG"] = old_config - else: - os.environ.pop("KRB5_CONFIG", None) - - -KRB5CONF_TEMPLATE = """ -[logging] - default = FILE:/var/log/krb5libs.log - kdc = FILE:/var/log/krb5kdc.log - admin_server = FILE:/var/log/kadmind.log - -[libdefaults] - default_realm = {{ ipa_realm }} - dns_lookup_realm = false - dns_lookup_kdc = true - rdns = false - ticket_lifetime = {{ ipa_lifetime }} - forwardable = true - udp_preference_limit = 0 - default_ccache_name = KEYRING:persistent:%{uid} - -[realms] - {{ ipa_realm }} = { - kdc = {{ ipa_server }}:88 - master_kdc = {{ ipa_server }}:88 - admin_server = {{ ipa_server }}:749 - default_domain = {{ ipa_domain }} -} - -[domain_realm] - .{{ ipa_domain }} = {{ ipa_realm }} - {{ ipa_domain }} = {{ ipa_realm }} -""" - - -class ActionModule(ActionBase): # pylint: disable=too-few-public-methods - - # pylint: disable=too-many-return-statements - def run(self, tmp=None, task_vars=None): - """ - Handle credential cache transfer. - - ipa* commands can either provide a password or a keytab file - in order to authenticate on the managed node with Kerberos. - The module is using these credentials to obtain a TGT locally on the - control node: - - need to create a krb5.conf Kerberos client configuration that is - using IPA server - - set the environment variable KRB5_CONFIG to point to this conf file - - set the environment variable KRB5CCNAME to use a specific cache - - perform kinit on the control node - This command creates the credential cache file - - copy the credential cache file on the managed node - - Then the IPA commands can use this credential cache file. - """ - if task_vars is None: - task_vars = {} - - # pylint: disable=super-with-arguments - result = super(ActionModule, self).run(tmp, task_vars) - principal = self._task.args.get('principal', None) - keytab = self._task.args.get('keytab', None) - password = self._task.args.get('password', None) - lifetime = self._task.args.get('lifetime', '1h') - - if (not keytab and not password): - result['failed'] = True - result['msg'] = "keytab or password is required" - return result - - if not principal: - result['failed'] = True - result['msg'] = "principal is required" - return result - - data = self._execute_module(module_name='ipaclient_get_facts', - module_args={}, task_vars=task_vars) - - try: - domain = data['ansible_facts']['ipa']['domain'] - realm = data['ansible_facts']['ipa']['realm'] - except KeyError: - result['failed'] = True - result['msg'] = "The host is not an IPA server" - return result - - items = principal.split('@') - if len(items) < 2: - principal = str('%s@%s' % (principal, realm)) - - # Locally create a temp directory to store krb5.conf and ccache - local_temp_dir = tempfile.mkdtemp() - krb5conf_name = os.path.join(local_temp_dir, 'krb5.conf') - ccache_name = os.path.join(local_temp_dir, 'ccache') - - # Create the krb5.conf from the template - template = Template(KRB5CONF_TEMPLATE) - content = template.render(dict( - ipa_server=task_vars['ansible_host'], - ipa_domain=domain, - ipa_realm=realm, - ipa_lifetime=lifetime)) - - with open(krb5conf_name, 'w') as f: # pylint: disable=invalid-name - f.write(content) - - if password: - try: - # perform kinit -c ccache_name -l 1h principal - kinit_password(principal, password, ccache_name, - krb5conf_name) - except Exception as e: - result['failed'] = True - result['msg'] = 'kinit %s with password failed: %s' % \ - (principal, to_native(e)) - return result - - else: - # Password not supplied, need to use the keytab file - # Check if the source keytab exists - try: - keytab = self._find_needle('files', keytab) - except AnsibleError as e: - result['failed'] = True - result['msg'] = to_native(e) - return result - # perform kinit -kt keytab - try: - kinit_keytab(principal, keytab, ccache_name, krb5conf_name) - except Exception as e: - result['failed'] = True - result['msg'] = 'kinit %s with keytab %s failed: %s' % \ - (principal, keytab, str(e)) - return result - - try: - # Create the remote tmp dir - tmp = self._make_tmp_path() - tmp_ccache = self._connection._shell.join_path( - tmp, os.path.basename(ccache_name)) - - # Copy the ccache to the remote tmp dir - self._transfer_file(ccache_name, tmp_ccache) - self._fixup_perms2((tmp, tmp_ccache)) - - new_module_args = self._task.args.copy() - new_module_args.pop('password', None) - new_module_args.pop('keytab', None) - new_module_args.pop('lifetime', None) - new_module_args.update(ccache=tmp_ccache) - - # Execute module - result.update(self._execute_module(module_args=new_module_args, - task_vars=task_vars)) - return result - finally: - # delete the local temp directory - shutil.rmtree(local_temp_dir, ignore_errors=True) diff --git a/roles/ipaclient/library/ipaclient_get_facts.py b/roles/ipaclient/library/ipaclient_get_facts.py deleted file mode 100644 index e993139b..00000000 --- a/roles/ipaclient/library/ipaclient_get_facts.py +++ /dev/null @@ -1,282 +0,0 @@ -# -*- coding: utf-8 -*- - -# Authors: -# Thomas Woerner <twoerner@redhat.com> -# -# Based on ipa-client-install code -# -# Copyright (C) 2018-2022 Red Hat -# see file 'COPYING' for use and warranty information -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -from __future__ import (absolute_import, division, print_function) - -__metaclass__ = type - -DOCUMENTATION = """ ---- -module: ipaclient_get_facts -short_description: Get facts about IPA client and server configuration. -description: Get facts about IPA client and server configuration. -author: - - Thomas Woerner (@t-woerner) -""" - -EXAMPLES = """ -""" - -RETURN = """ -ipa: - description: IPA configuration - returned: always - type: complex - contains: - packages: - description: IPA lib and server bindings - type: dict - returned: always - contains: - ipalib: - description: Whether ipalib.api binding could be imported. - type: bool - returned: always - ipaserver: - description: Whether ipaserver binding could be imported. - type: bool - returned: always - configured: - description: IPA components - type: dict - returned: always - contains: - client: - description: Whether client is configured - type: bool - returned: always - server: - description: Whether server is configured - type: bool - returned: always - dns: - description: Whether dns is configured - type: bool - returned: always - ca: - description: Whether ca is configured - type: bool - returned: always - kra: - description: Whether kra is configured - type: bool - returned: always - ntpd: - description: Whether ntpd is configured - type: bool - returned: always -""" - -import os -import re -from ansible.module_utils import six -try: - from ansible.module_utils.six.moves.configparser import RawConfigParser -except ImportError: - from ConfigParser import RawConfigParser - -from ansible.module_utils.basic import AnsibleModule - -# pylint: disable=unused-import -try: - from ipalib import api # noqa: F401 -except ImportError: - HAS_IPALIB = False -else: - HAS_IPALIB = True - from ipaplatform.paths import paths - try: - # FreeIPA >= 4.5 - from ipalib.install import sysrestore - except ImportError: - # FreeIPA 4.4 and older - from ipapython import sysrestore - -try: - import ipaserver # noqa: F401 -except ImportError: - HAS_IPASERVER = False -else: - HAS_IPASERVER = True - -SERVER_SYSRESTORE_STATE = "/var/lib/ipa/sysrestore/sysrestore.state" -NAMED_CONF = "/etc/named.conf" -VAR_LIB_PKI_TOMCAT = "/var/lib/pki/pki-tomcat" - - -def is_ntpd_configured(): - # ntpd is configured when sysrestore.state contains the line - # [ntpd] - ntpd_conf_section = re.compile(r'^\s*\[ntpd\]\s*$') - - try: - # pylint: disable=invalid-name - with open(SERVER_SYSRESTORE_STATE) as f: - for line in f.readlines(): - if ntpd_conf_section.match(line): - return True - # pylint: enable=invalid-name - return False - except IOError: - return False - - -def is_dns_configured(): - # dns is configured when /etc/named.conf contains the line - # dyndb "ipa" "/usr/lib64/bind/ldap.so" { - bind_conf_section = re.compile(r'^\s*dyndb\s+"ipa"\s+"[^"]+"\s+{$') - - try: - with open(NAMED_CONF) as f: # pylint: disable=invalid-name - for line in f.readlines(): - if bind_conf_section.match(line): - return True - return False - except IOError: - return False - - -def is_dogtag_configured(subsystem): - # ca / kra is configured when the directory - # /var/lib/pki/pki-tomcat/[ca|kra] # exists - available_subsystems = {'ca', 'kra'} - if subsystem not in available_subsystems: - raise AssertionError("Subsystem '%s' not available" % subsystem) - - return os.path.isdir(os.path.join(VAR_LIB_PKI_TOMCAT, subsystem)) - - -def is_ca_configured(): - return is_dogtag_configured('ca') - - -def is_kra_configured(): - return is_dogtag_configured('kra') - - -def is_client_configured(): - # IPA Client is configured when /etc/ipa/default.conf exists - # and /var/lib/ipa-client/sysrestore/sysrestore.state exists - - fstore = sysrestore.FileStore(paths.IPA_CLIENT_SYSRESTORE) - return os.path.isfile(paths.IPA_DEFAULT_CONF) and fstore.has_files() - - -def is_server_configured(): - # IPA server is configured when /etc/ipa/default.conf exists - # and /var/lib/ipa/sysrestore/sysrestore.state exists - return (os.path.isfile(paths.IPA_DEFAULT_CONF) and - os.path.isfile(SERVER_SYSRESTORE_STATE)) - - -def get_ipa_conf(): - # Extract basedn, realm and domain from /etc/ipa/default.conf - parser = RawConfigParser() - parser.read(paths.IPA_DEFAULT_CONF) - basedn = parser.get('global', 'basedn') - realm = parser.get('global', 'realm') - domain = parser.get('global', 'domain') - return dict( - basedn=basedn, - realm=realm, - domain=domain - ) - - -def get_ipa_version(): - try: - # pylint: disable=import-outside-toplevel - from ipapython import version - # pylint: enable=import-outside-toplevel - except ImportError: - return None - else: - version_info = [] - for part in version.VERSION.split('.'): - # DEV versions look like: - # 4.4.90.201610191151GITd852c00 - # 4.4.90.dev201701071308+git2e43db1 - # 4.6.90.pre2 - if part.startswith('dev') or part.startswith('pre') or \ - 'GIT' in part: - version_info.append(part) - else: - version_info.append(int(part)) - - return dict( - api_version=version.API_VERSION, - num_version=version.NUM_VERSION, - vendor_version=version.VENDOR_VERSION, - version=version.VERSION, - version_info=version_info - ) - - -def main(): - module = AnsibleModule( - argument_spec={}, - supports_check_mode=True - ) - - # The module does not change anything, meaning that - # check mode is supported - - facts = dict( - packages=dict( - ipalib=HAS_IPALIB, - ipaserver=HAS_IPASERVER, - ), - configured=dict( - client=False, - server=False, - dns=False, - ca=False, - kra=False, - ntpd=False - ) - ) - - if HAS_IPALIB: - if is_client_configured(): - facts['configured']['client'] = True - - facts['version'] = get_ipa_version() - for key, value in six.iteritems(get_ipa_conf()): - facts[key] = value - - if HAS_IPASERVER: - if is_server_configured(): - facts['configured']['server'] = True - facts['configured']['dns'] = is_dns_configured() - facts['configured']['ca'] = is_ca_configured() - facts['configured']['kra'] = is_kra_configured() - facts['configured']['ntpd'] = is_ntpd_configured() - - module.exit_json( - changed=False, - ansible_facts=dict(ipa=facts) - ) - - -if __name__ == '__main__': - main() diff --git a/roles/ipaclient/library/ipaclient_get_otp.py b/roles/ipaclient/library/ipaclient_get_otp.py index 6be32063..b69a30cf 100644 --- a/roles/ipaclient/library/ipaclient_get_otp.py +++ b/roles/ipaclient/library/ipaclient_get_otp.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- # Authors: -# Florence Blanc-Renaud <frenaud@redhat.com> +# Thomas Woerner <twoerner@redhat.com> # -# Copyright (C) 2017-2022 Red Hat +# Copyright (C) 2019-2022 Red Hat # see file 'COPYING' for use and warranty information # # This program is free software; you can redistribute it and/or modify @@ -23,324 +23,267 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type -ANSIBLE_METADATA = {'metadata_version': '1.0', - 'status': ['preview'], - 'supported_by': 'community'} +ANSIBLE_METADATA = { + "metadata_version": "1.0", + "supported_by": "community", + "status": ["preview"], +} -DOCUMENTATION = ''' +DOCUMENTATION = """ --- module: ipaclient_get_otp -short_description: Manage IPA hosts -description: - Manage hosts in a IPA domain. - The operation needs to be authenticated with Kerberos either by providing - a password or a keytab corresponding to a principal allowed to perform - host operations. +short_description: Get OTP for host enrollment +description: Get OTP for host enrollment options: - principal: - description: - User Principal allowed to promote replicas and join IPA realm - type: str - required: no + ipaadmin_principal: + description: The admin principal. default: admin - ccache: - description: The local ccache - type: path - required: no - fqdn: - description: - The fully-qualified hostname of the host to add/modify/remove type: str - required: yes - certificates: - description: A list of host certificates - type: list - elements: str - required: no - sshpubkey: - description: The SSH public key for the host + ipaadmin_password: + description: | + The admin password. Either ipaadmin_password or ipaadmin_keytab needs + to be given. + required: false type: str - required: no - ipaddress: - description: The IP address for the host + ipaadmin_keytab: + description: | + The admin keytab. Either ipaadmin_password or ipaadmin_keytab needs + to be given. type: str - required: no - random: - description: Generate a random password to be used in bulk enrollment - type: bool - required: no - default: no - state: - description: The desired host state + required: false + hostname: + description: The FQDN hostname. type: str - choices: ['present', 'absent'] - default: present - required: no + required: true author: - - Florence Blanc-Renaud (@flo-renaud) -''' - -EXAMPLES = ''' -# Example from Ansible Playbooks -# Add a new host with a random OTP, authenticate using principal/password -- ipaclient_get_otp: - principal: admin - password: MySecretPassword - fqdn: ipaclient.ipa.domain.com - ipaddress: 192.168.100.23 - random: True - register: result_ipaclient_get_otp -''' - -RETURN = ''' + - Thomas Woerner (@t-woerner) +""" + +EXAMPLES = """ +""" + +RETURN = """ host: - description: the host structure as returned from IPA API + description: Host dict with random password returned: always - type: complex + type: dict contains: - dn: - description: the DN of the host entry - type: str - returned: always - fqdn: - description: the fully qualified host name - type: str - returned: always - has_keytab: - description: whether the host entry contains a keytab - type: bool - returned: always - has_password: - description: whether the host entry contains a password - type: bool - returned: always - managedby_host: - description: the list of hosts managing the host - type: list - returned: always randompassword: - description: the OneTimePassword generated for this host - type: str - returned: changed - certificates: - description: the list of host certificates - type: list - elements: str - returned: when present - sshpubkey: - description: the SSH public key for the host + description: The generated random password type: str - returned: when present - ipaddress: - description: the IP address for the host - type: str - returned: when present -''' +""" import os - +import tempfile +import shutil +from contextlib import contextmanager from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_text from ansible.module_utils import six -from ansible.module_utils.ansible_ipa_client import ( - check_imports, api, errors, paths, run -) +try: + from ipalib import api + from ipalib import errors as ipalib_errors # noqa + from ipalib.config import Env + from ipaplatform.paths import paths + from ipapython.ipautil import run + from ipalib.constants import DEFAULT_CONFIG + try: + from ipalib.install.kinit import kinit_password, kinit_keytab + except ImportError: + from ipapython.ipautil import kinit_password, kinit_keytab +except ImportError as _err: + MODULE_IMPORT_ERROR = str(_err) +else: + MODULE_IMPORT_ERROR = None + if six.PY3: unicode = str -def get_host_diff(ipa_host, module_host): - """ - Build a dict with the differences from two host dicts. +def temp_kinit(principal, password, keytab): + """Kinit with password or keytab using a temporary ccache.""" + ccache_dir = tempfile.mkdtemp(prefix='krbcc') + ccache_name = os.path.join(ccache_dir, 'ccache') - :param ipa_host: the host structure seen from IPA - :param module_host: the target host structure seen from the module params + try: + if password: + kinit_password(principal, password, ccache_name) + else: + kinit_keytab(principal, keytab, ccache_name) + except RuntimeError as e: + raise RuntimeError("Kerberos authentication failed: %s" % str(e)) - :return: a dict representing the host attributes to apply - """ - non_updateable_keys = ['ip_address'] - data = {} - for key in non_updateable_keys: - if key in module_host: - del module_host[key] - - for key in module_host.keys(): - ipa_value = ipa_host.get(key, None) - module_value = module_host.get(key, None) - if isinstance(ipa_value, list) and not isinstance(module_value, list): - module_value = [module_value] - if isinstance(ipa_value, list) and isinstance(module_value, list): - ipa_value = sorted(ipa_value) - module_value = sorted(module_value) - if ipa_value != module_value: - data[key] = unicode(module_value) - return data - - -def get_module_host(module): - """ - Create a structure representing the host information. + os.environ["KRB5CCNAME"] = ccache_name + return ccache_dir, ccache_name - Reads the module parameters and builds the host structure as expected from - the module - :param module: the ansible module - :returns: a dict representing the host attributes - """ - data = {} - certificates = module.params.get('certificates') - if certificates: - data['usercertificate'] = certificates - sshpubkey = module.params.get('sshpubkey') - if sshpubkey: - data['ipasshpubkey'] = unicode(sshpubkey) - ipaddress = module.params.get('ipaddress') - if ipaddress: - data['ip_address'] = unicode(ipaddress) - random = module.params.get('random') - if random: - data['random'] = random - return data - - -def ensure_host_present(module, _api, ipahost): + +def temp_kdestroy(ccache_dir, ccache_name): + """Destroy temporary ticket and remove temporary ccache.""" + if ccache_name is not None: + run([paths.KDESTROY, '-c', ccache_name], raiseonerr=False) + del os.environ['KRB5CCNAME'] + if ccache_dir is not None: + shutil.rmtree(ccache_dir, ignore_errors=True) + + +@contextmanager +def ipa_connect(module, principal=None, password=None, keytab=None): """ - Ensure host exists in IPA and has the same attributes. + Create a context with a connection to IPA API. + + Parameters + ---------- + module: AnsibleModule + The AnsibleModule to use + principal: string + The optional principal name + password: string + The optional password. Either password or keytab needs to be given. + keytab: string + The optional keytab. Either password or keytab needs to be given. - :param module: the ansible module - :param api: IPA api handle - :param ipahost: the host information present in IPA, can be none if the - host does not exist """ - fqdn = unicode(module.params.get('fqdn')) - if ipahost: - # Host already present, need to compare the attributes - module_host = get_module_host(module) - diffs = get_host_diff(ipahost, module_host) - - if not diffs: - # Same attributes, success - module.exit_json(changed=False, host=ipahost) - - # Need to modify the host - only if not in check_mode - if module.check_mode: - module.exit_json(changed=True) - - # If we want to create a random password, and the host - # already has Keytab: true, then we need first to run - # ipa host-disable in order to remove OTP and keytab - if module.params.get('random') and ipahost['has_keytab'] is True: - _api.Command.host_disable(fqdn) - - result = _api.Command.host_mod(fqdn, **diffs) - # Save random password as it is not displayed by host-show - if module.params.get('random'): - randompassword = result['result']['randompassword'] - result = _api.Command.host_show(fqdn) - if module.params.get('random'): - result['result']['randompassword'] = randompassword - module.exit_json(changed=True, host=result['result']) - - if not ipahost: - # Need to add the user, only if not in check_mode - if module.check_mode: - module.exit_json(changed=True) - - # Must add the user - module_host = get_module_host(module) - # force creation of host even if there is no DNS record - module_host["force"] = True - result = _api.Command.host_add(fqdn, **module_host) - # Save random password as it is not displayed by host-show - if module.params.get('random'): - randompassword = result['result']['randompassword'] - result = _api.Command.host_show(fqdn) - if module.params.get('random'): - result['result']['randompassword'] = randompassword - module.exit_json(changed=True, host=result['result']) - - -def ensure_host_absent(module, _api, host): + if not password and not keytab: + module.fail_json(msg="One of password and keytab is required.") + if not principal: + principal = "admin" + + ccache_dir = None + ccache_name = None + try: + ccache_dir, ccache_name = temp_kinit(principal, password, keytab) + # api_connect start + env = Env() + env._bootstrap() + env._finalize_core(**dict(DEFAULT_CONFIG)) + + api.bootstrap(context="server", debug=env.debug, log=None) + api.finalize() + + if api.env.in_server: + backend = api.Backend.ldap2 + else: + backend = api.Backend.rpcclient + + if not backend.isconnected(): + backend.connect(ccache=ccache_name) + # api_connect end + except Exception as e: + module.fail_json(msg=str(e)) + else: + try: + yield ccache_name + except Exception as e: + module.fail_json(msg=str(e)) + finally: + temp_kdestroy(ccache_dir, ccache_name) + + +def ipa_command(command, name, args): """ - Ensure host does not exist in IPA. + Execute an IPA API command with a required `name` argument. + + Parameters + ---------- + command: string + The IPA API command to execute. + name: string + The name parameter to pass to the command. + args: dict + The parameters to pass to the command. - :param module: the ansible module - :param api: the IPA API handle - :param host: the host information present in IPA, can be none if the - host does not exist """ - if not host: - # Nothing to do, host already removed - module.exit_json(changed=False) + return api.Command[command](name, **args) - # Need to remove the host - only if not in check_mode - if module.check_mode: - module.exit_json(changed=True, host=host) - fqdn = unicode(module.params.get('fqdn')) +def _afm_convert(value): + if value is not None: + if isinstance(value, list): + return [_afm_convert(x) for x in value] + if isinstance(value, dict): + return {_afm_convert(k): _afm_convert(v) + for k, v in value.items()} + if isinstance(value, str): + return to_text(value) + + return value + + +def module_params_get(module, name): + return _afm_convert(module.params.get(name)) + + +def host_show(module, name): + _args = { + "all": True, + } + try: - _api.Command.host_del(fqdn) - except Exception as e: - module.fail_json(msg="Failed to remove host: %s" % e) + _result = ipa_command("host_show", name, _args) + except ipalib_errors.NotFound as e: + msg = str(e) + if "host not found" in msg: + return None + module.fail_json(msg="host_show failed: %s" % msg) - module.exit_json(changed=True) + return _result["result"] def main(): - module = AnsibleModule( argument_spec=dict( - principal=dict(required=False, type='str', default='admin'), - ccache=dict(required=False, type='path'), - fqdn=dict(required=True, type='str'), - certificates=dict(required=False, type='list', elements='str'), - sshpubkey=dict(required=False, type='str'), - ipaddress=dict(required=False, type='str'), - random=dict(required=False, type='bool', default=False), - state=dict(required=False, type='str', - choices=['present', 'absent'], default='present'), + ipaadmin_principal=dict(type="str", default="admin"), + ipaadmin_password=dict(type="str", required=False, no_log=True), + ipaadmin_keytab=dict(type="str", required=False, no_log=False), + hostname=dict(type="str", required=True), ), + mutually_exclusive=[["ipaadmin_password", "ipaadmin_keytab"]], supports_check_mode=True, ) - check_imports(module) + if MODULE_IMPORT_ERROR is not None: + module.fail_json(msg=MODULE_IMPORT_ERROR) - ccache = module.params.get('ccache') - fqdn = unicode(module.params.get('fqdn')) - state = module.params.get('state') + # In check mode always return changed. + if module.check_mode: + module.exit_json(changed=True) - try: - os.environ['KRB5CCNAME'] = ccache - - cfg = dict( - context='ansible_module', - confdir=paths.ETC_IPA, - in_server=False, - debug=False, - verbose=0, - ) - api.bootstrap(**cfg) - api.finalize() - api.Backend.rpcclient.connect() + ipaadmin_principal = module_params_get(module, "ipaadmin_principal") + ipaadmin_password = module_params_get(module, "ipaadmin_password") + ipaadmin_keytab = module_params_get(module, "ipaadmin_keytab") + if ipaadmin_keytab: + if not os.path.exists(ipaadmin_keytab): + module.fail_json(msg="Unable to open ipaadmin_keytab '%s'" % + ipaadmin_keytab) - try: - result = api.Command.host_show(fqdn, all=True) - host = result['result'] - except errors.NotFound: - host = None + hostname = module_params_get(module, "hostname") - if state in ['present', 'disabled']: - ensure_host_present(module, api, host) - elif state == 'absent': - ensure_host_absent(module, api, host) + exit_args = {} - except Exception as e: - module.fail_json(msg="ipaclient_get_otp module failed : %s" % str(e)) - finally: - run([paths.KDESTROY], raiseonerr=False, env=os.environ) + # Connect to IPA API + with ipa_connect(module, ipaadmin_principal, ipaadmin_password, + ipaadmin_keytab): + res_show = host_show(module, hostname) + + args = {"random": True} + if res_show is None: + # Create new host, force is needed to create the host without + # IP address. + args["force"] = True + result = ipa_command("host_add", hostname, args) + else: + # If host exists and has a keytab (is enrolled) then disable the + # host to be able to create a new OTP. + if res_show["has_keytab"]: + ipa_command("host_disable", hostname, {}) + result = ipa_command("host_mod", hostname, args) + + exit_args["randompassword"] = result['result']['randompassword'] - module.exit_json(changed=False, host=host) + module.exit_json(changed=True, host=exit_args) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/roles/ipaclient/tasks/install.yml b/roles/ipaclient/tasks/install.yml index 46cfc3aa..2059a4aa 100644 --- a/roles/ipaclient/tasks/install.yml +++ b/roles/ipaclient/tasks/install.yml @@ -18,9 +18,9 @@ when: ipaclient_no_dns_lookup | bool and groups.ipaserver is defined and ipaclient_servers is not defined -- name: Install - Check that either principal or keytab is set - fail: msg="ipaadmin_principal and ipaadmin_keytab cannot be used together" - when: ipaadmin_keytab is defined and ipaadmin_principal is defined +- name: Install - Check that either password or keytab is set + fail: msg="ipaadmin_password and ipaadmin_keytab cannot be used together" + when: ipaadmin_keytab is defined and ipaadmin_password is defined - name: Install - Set default principal if no keytab is given set_fact: @@ -99,35 +99,44 @@ not ipaclient_force_join | bool # The following block is executed when using OTP to enroll IPA client and - # the OTP isn't predefined, ie when ipaclient_use_otp is set and ipaclient_otp - # is not set. + # the OTP isn't predefined, ie when ipaclient_use_otp is set and + # ipaclient_otp is not set. # It connects to ipaserver and add the host with --random option in order # to create a OneTime Password # If a keytab is specified in the hostent, then the hostent will be disabled # if ipaclient_use_otp is set. - block: - name: Install - Keytab or password is required for getting otp - fail: msg="Keytab or password is required for getting otp" + ansible.builtin.fail: + msg: Keytab or password is required for getting otp when: ipaadmin_keytab is undefined and ipaadmin_password is undefined + - name: Install - Create temporary file for keytab + ansible.builtin.tempfile: + state: file + prefix: ipaclient_temp_ + path: /root + register: keytab_temp + delegate_to: "{{ result_ipaclient_test.servers[0] }}" + when: ipaadmin_keytab is defined + + - name: Install - Copy keytab to server temporary file + ansible.builtin.copy: + src: "{{ ipaadmin_keytab }}" + dest: "{{ keytab_temp.path }}" + mode: 0600 + delegate_to: "{{ result_ipaclient_test.servers[0] }}" + when: ipaadmin_keytab is defined + - name: Install - Get One-Time Password for client enrollment no_log: yes ipaclient_get_otp: - state: present - principal: "{{ ipaadmin_principal | default(omit) }}" - password: "{{ ipaadmin_password | default(omit) }}" - keytab: "{{ ipaadmin_keytab | default(omit) }}" - fqdn: "{{ result_ipaclient_test.hostname }}" - lifetime: "{{ ipaclient_lifetime | default(omit) }}" - random: True + ipaadmin_principal: "{{ ipaadmin_principal | default(omit) }}" + ipaadmin_password: "{{ ipaadmin_password | default(omit) }}" + ipaadmin_keytab: "{{ keytab_temp.path | default(omit) }}" + hostname: "{{ result_ipaclient_test.hostname }}" register: result_ipaclient_get_otp - # If the host is already enrolled, this command will exit on error - # The error can be ignored - failed_when: result_ipaclient_get_otp is failed and - "Password cannot be set on enrolled host" not - in result_ipaclient_get_otp.msg delegate_to: "{{ result_ipaclient_test.servers[0] }}" - ignore_errors: yes - name: Install - Report error for OTP generation debug: @@ -144,6 +153,14 @@ when: ipaclient_use_otp | bool and ipaclient_otp is not defined + always: + - name: Install - Remove keytab temporary file + ansible.builtin.file: + path: "{{ keytab_temp.path }}" + state: absent + delegate_to: "{{ result_ipaclient_test.servers[0] }}" + when: keytab_temp.path is defined + - name: Store predefined OTP in admin_password no_log: yes set_fact: @@ -161,7 +178,7 @@ # result_ipaclient_join.already_joined))) - name: Install - Check if principal and keytab are set - fail: msg="Principal and keytab cannot be used together" + fail: msg="Admin principal and client keytab cannot be used together" when: ipaadmin_principal is defined and ipaclient_keytab is defined - name: Install - Check if one of password or keytabs are set diff --git a/utils/build-galaxy-release.sh b/utils/build-galaxy-release.sh index a708d674..bbce0122 100755 --- a/utils/build-galaxy-release.sh +++ b/utils/build-galaxy-release.sh @@ -123,10 +123,12 @@ sed -i -e "s/ansible.module_utils.ansible_freeipa_module/ansible_collections.${c ln -sf ../../roles/*/library/*.py . }) -[ ! -x plugins/action ] && mkdir plugins/action -(cd plugins/action && { - ln -sf ../../roles/*/action_plugins/*.py . -}) +# There are no action plugins anymore in the roles, therefore this section +# is commneted out. +#[ ! -x plugins/action ] && mkdir plugins/action +#(cd plugins/action && { +# ln -sf ../../roles/*/action_plugins/*.py . +#}) for doc_fragment in plugins/doc_fragments/*.py; do fragment=$(basename -s .py "$doc_fragment") -- GitLab