commit 09f45e4acd702e084e7a2603ae56fc5b69270d8b Author: Florence Blanc-Renaud Date: Mon Jul 3 09:55:23 2017 +0200 Ansible for IPA diff --git a/action_plugins/ipahost.py b/action_plugins/ipahost.py new file mode 100644 index 00000000..3854dcec --- /dev/null +++ b/action_plugins/ipahost.py @@ -0,0 +1,77 @@ +# Authors: +# Florence Blanc-Renaud +# +# 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 . + +import os + +from ansible.errors import AnsibleError +from ansible.module_utils._text import to_native +from ansible.plugins.action import ActionBase + +class ActionModule(ActionBase): + def run(self, tmp=None, task_vars=None): + """ + handler for file transfer operations + + ipa* commands can either provide a password or a keytab file + in order to authenticate on the managed node with Kerberos. + When a keytab is provided, it needs to be copied from the control + node to the managed node. + This Action Module performs the copy when needed. + """ + + if task_vars is None: + task_vars = dict() + + result = super(ActionModule, self).run(tmp, task_vars) + keytab = self._task.args.get('keytab', None) + password = self._task.args.get('password', None) + + if (keytab is None and password is None): + result['failed'] = True + result['msg'] = "keytab or password is required" + return result + + # If password is supplied, just need to execute the module + if password: + result.update(self._execute_module(task_vars=task_vars)) + return result + + # Password not supplied, need to transfer 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 + + # Create the remote tmp dir + tmp = self._make_tmp_path() + tmp_keytab = self._connection._shell.join_path( + tmp, os.path.basename(keytab)) + self._transfer_file(keytab, tmp_keytab) + self._fixup_perms2((tmp, tmp_keytab)) + + new_module_args = self._task.args.copy() + new_module_args.update(dict(keytab=tmp_keytab)) + + # Execute module + result.update(self._execute_module(module_args=new_module_args, task_vars=task_vars)) + self._remove_tmp_path(tmp) + return result diff --git a/action_plugins/ipahost.pyc b/action_plugins/ipahost.pyc new file mode 100644 index 00000000..5ef86f65 Binary files /dev/null and b/action_plugins/ipahost.pyc differ diff --git a/inventory/hosts b/inventory/hosts new file mode 100644 index 00000000..a3a3ccf4 --- /dev/null +++ b/inventory/hosts @@ -0,0 +1,15 @@ +[ipaclients] +ipaclient.ipadomain.com + +[ipaservers] +ipaserver.ipadomain.com + +[ipaclients:vars] +ipaclient_domain=ipadomain.com +ipaclient_realm=IPADOMAIN.COM +ipaclient_server=ipaserver.ipadomain.com +ipaclient_extraargs=[ '--kinit-attempts=3', '--mkhomedir'] + +[ipaservers:vars] +ipa_admin=admin +ipa_password=MySecretPassword123 diff --git a/library/ipaclient.py b/library/ipaclient.py new file mode 100644 index 00000000..37bd9c5b --- /dev/null +++ b/library/ipaclient.py @@ -0,0 +1,298 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Authors: +# Florence Blanc-Renaud +# +# 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 . + +ANSIBLE_METADATA = {'metadata_version': '1.0', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: ipaclient +short description: Configures a client machine as IPA client +description: + Configures a client machine to use IPA for authentication and + identity services. + The enrollment requires one authentication method among the 3 following: + - Kerberos principal and password (principal/password) + - Kerberos keytab file (keytab) + - One-Time-Password (otp) +options: + state: + description: the client state + required: false + default: present + choices: [ "present", "absent"] + domain: + description: The primary DNS domain of an existing IPA deployment. + required: false + realm: + description: The Kerberos realm of an existing IPA deployment. + required: false + server: + description: The FQDN of the IPA server to connect to. + required: false + principal: + description: The authorized kerberos principal used to join the IPA realm. + required: false + default: admin + password: + description: The password for the kerberos principal. + required: false + keytab: + description: The pathto a backed-up host keytab from previous enrollment. + required: false + otp: + description: The One-Time-Password used to join the IPA realm. + required: false + extr_args: + description: The list of extra arguments to provide to ipa-client-install. + required: false + type: list +author: + - Florence Blanc-Renaud +''' + +EXAMPLES = ''' +# Example from Ansible Playbooks +# Unenroll client +- ipaclient: + state: absent + +# Enroll client using admin credentials, with auto-discovery +- ipaclient: + principal: admin + password: MySecretPassword + extraargs: [ '--no-ntp', '--kinit-attempts=5'] + +# Enroll client using admin credentials, with specified domain and +# autodiscovery of the IPA server +- ipaclient: + principal: admin + password: MySecretPassword + domain: ipa.domain.com + extraargs: [ '--no-ntp', '--kinit-attempts=5'] + +# Enroll client using admin credentials, with specified server +- ipaclient: + principal: admin + password: MySecretPassword + domain: ipa.domain.com + server: ipaserver.ipa.domain.com + extraargs: [ '--no-ntp', '--kinit-attempts=5'] + +# Enroll client using One-Time-Password, with specified domain and realm +- ipaclient: + domain: ipa.domain.com + realm: IPA.DOMAIN.com + otp: 9Mn*Jm8z[%n]|:CJeu>Y~K + +# Re-enroll client using keytab stored on the managed node +- ipaclient: + domain: ipa.domain.com + realm: IPA.DOMAIN.com + keytab: /path/to/host.keytab +''' + +RETURN = ''' +tbd +''' + +import os +from six.moves.configparser import RawConfigParser + +from ansible.module_utils.basic import AnsibleModule + +from ipalib.install.sysrestore import SYSRESTORE_STATEFILE +from ipaplatform.paths import paths + + +def is_client_configured(): + """ + Check if ipa client is configured. + + IPA client is configured when /etc/ipa/default.conf exists and + /var/lib/ipa-client/sysrestore/sysrestore.state exists. + + :returns: boolean + """ + + return (os.path.isfile(paths.IPA_DEFAULT_CONF) and + os.path.isfile(os.path.join(paths.IPA_CLIENT_SYSRESTORE, + SYSRESTORE_STATEFILE))) + + +def get_ipa_conf(): + """ + Return IPA configuration read from /etc/ipa/default.conf + + :returns: dict containing key,value + """ + + parser = RawConfigParser() + parser.read(paths.IPA_DEFAULT_CONF) + result = dict() + for item in ['basedn', 'realm', 'domain', 'server', 'host', 'xmlrpc_uri']: + value = parser.get('global', item) + if value: + result[item] = value + + return result + + +def ensure_not_ipa_client(module): + """ + Module for client uninstallation + + If IPA client is installed, calls ipa-client-install --uninstall -U + :param module: AnsibleModule + """ + + # Check if IPA client is already configured + if not is_client_configured(): + # Nothing to do + module.exit_json(changed=False) + + # Client is configured + # If in check mode, do nothing but return changed=True + if module.check_mode: + module.exit_json(changed=True) + + # Client is configured and we want to remove it + cmd = [ + module.get_bin_path('ipa-client-install'), + "--uninstall", + "-U", + ] + retcode, stdout, stderr = module.run_command(cmd) + if retcode != 0: + module.fail_json(msg="Failed to uninstall IPA client: %s" % stderr) + + module.exit_json(changed=True) + + +def ensure_ipa_client(module): + """ + Module for client installation + + If IPA client is not installed, calls ipa-client-install + :param module: AnsibleModule + """ + + domain = module.params.get('domain') + realm = module.params.get('realm') + server = module.params.get('server') + principal = module.params.get('principal') + password = module.params.get('password') + keytab = module.params.get('keytab') + otp = module.params.get('otp') + extra_args = module.params.get('extra_args') + + # Ensure that at least one auth method is specified + if not (password or keytab or otp): + module.fail_json(msg="At least one of password, keytab or otp " + "must be specified") + + # Check if ipa client is already configured + if is_client_configured(): + # Check that realm and domain match + current_config = get_ipa_conf() + if domain and domain != current_config.get('domain'): + return module.fail_json(msg="IPA client already installed " + "with a conflicting domain") + if realm and realm != current_config.get('realm'): + return module.fail_json(msg="IPA client already installed " + "with a conflicting realm") + + # client is already configured and no inconsistency detected + return module.exit_json(changed=False, domain=domain, realm=realm) + + # ipa client not installed + if module.check_mode: + # Do nothing, just return changed=True + return module.exit_json(changed=True) + + cmd = [ + module.get_bin_path("ipa-client-install"), + "-U", + ] + if domain: + cmd.append("--domain") + cmd.append(domain) + if realm: + cmd.append("--realm") + cmd.append(realm) + if server: + cmd.append("--server") + cmd.append(server) + if password: + cmd.append("--password") + cmd.append(password) + cmd.append("--principal") + cmd.append(principal) + if keytab: + cmd.append("--keytab") + cmd.append(keytab) + if otp: + cmd.append("--password") + cmd.append(otp) + if extra_args: + for extra_arg in extra_args: + cmd.append(extra_arg) + + retcode, stdout, stderr = module.run_command(cmd) + if retcode != 0: + module.fail_json(msg="Failed to install IPA client: %s" % stderr) + + # If autodiscovery was used, need to read /etc/ipa/default.conf to + # find domain and realm + new_config = get_ipa_conf() + module.exit_json(changed=True, + domain=new_config.get('domain'), + realm=new_config.get('realm')) + + +def main(): + module = AnsibleModule( + supports_check_mode=True, + argument_spec=dict( + state=dict(default='present', choices=['present', 'absent']), + domain=dict(required=False), + realm=dict(required=False), + server=dict(required=False), + principal=dict(default='admin'), + password=dict(required=False, no_log=True), + keytab=dict(required=False, type='path'), + otp=dict(required=False), + extra_args=dict(default=None, type='list') + ), + ) + + module._ansible_debug = True + state = module.params.get('state') + + if state == 'present': + ensure_ipa_client(module) + else: + ensure_not_ipa_client(module) + +if __name__ == '__main__': + main() diff --git a/library/ipahost.py b/library/ipahost.py new file mode 100644 index 00000000..c4914d61 --- /dev/null +++ b/library/ipahost.py @@ -0,0 +1,316 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Authors: +# Florence Blanc-Renaud +# +# 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 . + +ANSIBLE_METADATA = {'metadata_version': '1.0', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: ipahost +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. +options: + principal: + description: Kerberos principal used to manage the host + required: false + default: admin + password: + description: Password for the kerberos principal + required: false + keytab: + description: Keytab file containing the Kerberos principal and encrypted key + required: false + fqdn: + description: the fully-qualified hostname of the host to add/modify/remove + required: true + random: + description: generate a random password to be used in bulk enrollment + type: bool + state: + description: the host state + required: false + default: present + choices: [ "present", "absent" ] + certificates: + description: a list of host certificates + required: false + type: list + sshpubkey: + description: the SSH public key for the host + required: false + ipaddress: + description: the IP address for the host + required: false + +author: + - "Florence Blanc-Renaud" +''' + +EXAMPLES = ''' +# Example from Ansible Playbooks +# Add a new host with a random OTP, authenticate using principal/password +- ipahost: + principal: admin + password: MySecretPassword + fqdn: ipaclient.ipa.domain.com + ipaddress: 192.168.100.23 + random: True + register: ipahost + +# Add a new host, authenticate with a keytab stored on the controller node +- ipahost: + keytab: admin.keytab + fqdn: ipaclient.ipa.domain.com + +# Remove a host, authenticate using principal/password +- ipahost: + principal: admin + password: MySecretPassword + fqdn: ipaclient.ipa.domain.com + state: absent + +# Modify a host, add ssh public key: +- ipahost: + principal: admin + password: MySecretPassword + fqdn: ipaclient.ipa.domain.com + sshpubkey: ssh-rsa AAAA... + +''' + +RETURN = ''' +tbd +''' + +import os +import tempfile + +from ansible.module_utils.basic import AnsibleModule + +from ipalib import api, errors, x509 +from ipalib.install.kinit import kinit_keytab, kinit_password +from ipaplatform.paths import paths +from ipapython.ipautil import run + +def get_host_diff(ipa_host, module_host): + """ + Compares two dictionaries containing host attributes and builds a dict + of differences. + + :param ipa_host: the host structure seen from IPA + :param module_host: the target host structure seen from the module params + + :return: a dict representing the host attributes to apply + """ + non_updateable_keys = ['ip_address'] + data = dict() + 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): + """ + Creates a structure representing the host information + + 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 = dict() + 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): + """ + Ensures that the host exists in IPA and has the same attributes. + + :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) + + 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) + 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): + """ + Ensures that the host does not exist in IPA + + :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) + + # 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')) + try: + api.Command.host_del(fqdn) + except Exception as e: + module.fail_json(msg="Failed to remove host: %s" % e) + + module.exit_json(changed=True) + + +def main(): + """ + Main routine for the ansible module. + """ + module = AnsibleModule( + argument_spec=dict( + keytab = dict(required=False, type='path'), + principal = dict(default='admin'), + password = dict(required=False, no_log=True), + fqdn = dict(required=True), + certificates = dict(required=False, type='list'), + sshpubkey= dict(required=False), + ipaddress = dict(required=False), + random = dict(default=False, type='bool'), + state = dict(default='present', choices=[ 'present', 'absent' ]), + ), + required_one_of=[ [ 'password', 'keytab'], ], + mutually_exclusive=[ [ 'password', 'keytab' ], ], + supports_check_mode=True, + ) + + principal = module.params.get('principal', 'admin') + password = module.params.get('password') + keytab = module.params.get('keytab') + fqdn = unicode(module.params.get('fqdn')) + state = module.params.get('state') + + try: + ccache_dir = tempfile.mkdtemp(prefix='krbcc') + ccache_name = os.path.join(ccache_dir, 'ccache') + + if keytab: + kinit_keytab(principal, keytab, ccache_name) + elif password: + kinit_password(principal, password, ccache_name) + + os.environ['KRB5CCNAME'] = ccache_name + 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() + + changed = False + try: + result = api.Command.host_show(fqdn, all=True) + host = result['result'] + except errors.NotFound: + host = None + + if state == 'present' or state == 'disabled': + changed = ensure_host_present(module, api, host) + elif state == 'absent': + changed = ensure_host_absent(module, api, host) + + except Exception as e: + module.fail_json(msg="ipahost module failed : %s" % str(e)) + finally: + run(["kdestroy"], raiseonerr=False, env=os.environ) + + module.exit_json(changed=changed, host=host) + +if __name__ == '__main__': + main() diff --git a/roles/ipaclient/defaults/main.yml b/roles/ipaclient/defaults/main.yml new file mode 100644 index 00000000..e8490871 --- /dev/null +++ b/roles/ipaclient/defaults/main.yml @@ -0,0 +1,12 @@ +--- +# defaults file for ipaclient + +ipaclient_domain: +ipaclient_realm: +ipaclient_server: + +ipaclient_principal: +ipaclient_password: +ipaclient_keytab: +ipaclient_otp: +ipaclient_extraargs: [] diff --git a/roles/ipaclient/meta/main.yml b/roles/ipaclient/meta/main.yml new file mode 100644 index 00000000..6e713b45 --- /dev/null +++ b/roles/ipaclient/meta/main.yml @@ -0,0 +1,24 @@ +galaxy_info: + author: Florence Blanc-Renaud + description: A role to join a machine to an IPA domain + company: Red Hat, Inc + + # issue_tracker_url: http://example.com/issue/tracker + + license: GPLv3 + + min_ansible_version: 2.0 + + #github_branch: + + platforms: + - name: Fedora + versions: + - 25 + - name: rhel + versions: + - 7 + + galaxy_tags: [ 'identity', 'ipa'] + +dependencies: [] diff --git a/roles/ipaclient/tasks/install.yml b/roles/ipaclient/tasks/install.yml new file mode 100644 index 00000000..cc6ce0b0 --- /dev/null +++ b/roles/ipaclient/tasks/install.yml @@ -0,0 +1,19 @@ +--- +# tasks file for ipaclient + +- name: Install - Install IPA client package + package: + name: "{{ ipaclient_package }}" + state: present + +- name: Install - Configure IPA client + ipaclient: + state: present + domain: "{{ ipaclient_domain }}" + realm: "{{ ipaclient_realm }}" + server: "{{ ipaclient_server }}" + principal: "{{ ipaclient_principal }}" + password: "{{ ipaclient_password }}" + keytab: "{{ ipaclient_keytab }}" + otp: "{{ ipaclient_otp }}" + extra_args: "{{ ipaclient_extraargs }}" diff --git a/roles/ipaclient/tasks/main.yml b/roles/ipaclient/tasks/main.yml new file mode 100644 index 00000000..152f1c58 --- /dev/null +++ b/roles/ipaclient/tasks/main.yml @@ -0,0 +1,16 @@ +--- +# tasks file for ipaclient + +- name: Import variables specific to distribution + include_vars: "{{ item }}" + with_first_found: + - vars/{{ ansible_distribution }}.yml + - vars/default.yml + +- name: Install IPA client + include: tasks/install.yml + when: state|default('present') == 'present' + +- name: Uninstall IPA client + include: tasks/uninstall.yml + when: state|default('present') == 'absent' diff --git a/roles/ipaclient/tasks/uninstall.yml b/roles/ipaclient/tasks/uninstall.yml new file mode 100644 index 00000000..8fd3d4b6 --- /dev/null +++ b/roles/ipaclient/tasks/uninstall.yml @@ -0,0 +1,11 @@ +--- +# tasks to uninstall IPA client + +- name: Uninstall - Uninstall IPA client + ipaclient: + state: absent + +#- name: Remove IPA client package +# package: +# name: "{{ ipaclient_package }}" +# state: absent diff --git a/roles/ipaclient/vars/default.yml b/roles/ipaclient/vars/default.yml new file mode 100644 index 00000000..a0e63eaf --- /dev/null +++ b/roles/ipaclient/vars/default.yml @@ -0,0 +1,3 @@ +# defaults file for ipaclient +# defaults/fedora.yml +ipaclient_package: freeipa-client diff --git a/roles/ipaclient/vars/rhel.yml b/roles/ipaclient/vars/rhel.yml new file mode 100644 index 00000000..c36d57dd --- /dev/null +++ b/roles/ipaclient/vars/rhel.yml @@ -0,0 +1,4 @@ +# defaults file for ipaclient +# defaults/rhel.yml +ipaclient_package: ipa-client + diff --git a/site.yml b/site.yml new file mode 100644 index 00000000..43d5bcc4 --- /dev/null +++ b/site.yml @@ -0,0 +1,19 @@ +--- +- name: Playbook to install IPA clients + hosts: ipaclients + become: true + + pre_tasks: + + - name: For OTP client registration, add client and get OTP + ipahost: + keytab: files/admin.keytab + fqdn: "{{ ansible_fqdn }}" + random: True + register: ipahost + delegate_to: "{{ groups.ipaservers[0] }}" + + roles: + - role: ipaclient + state: present + ipaclient_otp: "{{ ipahost.host.randompassword }}"