diff --git a/README.md b/README.md index f3ff4aaaf81e693bd30ab33d944966b46997c569..34d8e69d980d2ad45819733797bc81c1a22d4b89 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 4804de73ce0ec3351180259aaa770d7e69017968..43a68df713691256f931ea16e3051c0b663abc27 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 d6c429fdb50170a96e1d5cf765b6e502a5cc10b2..0000000000000000000000000000000000000000 --- 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 e993139bc77dd3802472c401d428c6479976d5c7..0000000000000000000000000000000000000000 --- 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 6be320632c59c8cd2c157192ef1a1a64779c6cb0..b69a30cff187d10c8af9476160565e4c86d61ca8 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 46cfc3aa78cdb71e9548ff1172a66bb01854d1d5..2059a4aad812d0a5633899292ca5a5c7b26420da 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 a708d6745d36356eb4f6a8cd74e72e2a8f1b618e..bbce0122b37d2fad90e699225004a78e19f3deb6 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")