diff --git a/action_plugins/ipahost.py b/action_plugins/ipahost.py index 3854dcec805fb6d2dfd927a27d01666eb581fb98..d4bd4b020cc984fc0c473bb06b9124a08367c283 100644 --- a/action_plugins/ipahost.py +++ b/action_plugins/ipahost.py @@ -17,61 +17,226 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +import gssapi 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 +try: + from __main__ import display +except ImportError: + from ansible.utils.display import Display + display = Display() + +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 + + p = subprocess.Popen(args, stdin=p_in, stdout=p_out, stderr=p_err, + close_fds=True) + stdout, stderr = p.communicate(stdin) + + return p.returncode + + +def kinit_password(principal, password, ccache_name, config): + """ + Perform kinit using principal/password, with the specified config file + and store 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: + result = run_cmd(args, stdin=password) + return result + 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, with the specified config file + and store the TGT in ccache_name. + """ + old_config = os.environ.get('KRB5_CONFIG') + os.environ['KRB5_CONFIG'] = config + try: + name = gssapi.Name(principal, gssapi.NameType.kerberos_principal) + store = {'ccache': ccache_name, + 'client_keytab': keytab} + cred = gssapi.Credentials(name=name, store=store, usage='initiate') + return cred + 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): + def run(self, tmp=None, task_vars=None): """ - handler for file transfer operations + handler for credential cache transfer 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. + 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 = dict() 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 (keytab is None and password is None): + if (not keytab and not password): 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)) + if not principal: + result['failed'] = True + result['msg'] = "principal is required" return result - # Password not supplied, need to transfer the keytab file - # Check if the source keytab exists + data = self._execute_module(module_name='ipa_facts', module_args=dict(), + task_vars=None) try: - keytab = self._find_needle('files', keytab) - except AnsibleError as e: + domain = data['ansible_facts']['ipa']['domain'] + realm = data['ansible_facts']['ipa']['realm'] + except KeyError: result['failed'] = True - result['msg'] = to_native(e) + result['msg'] = "The host is not an IPA server" 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)) + items = principal.split('@') + if len(items) < 2: + principal = str('%s@%s' % (principal, realm)) - new_module_args = self._task.args.copy() - new_module_args.update(dict(keytab=tmp_keytab)) + # 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') - # Execute module - result.update(self._execute_module(module_args=new_module_args, task_vars=task_vars)) - self._remove_tmp_path(tmp) - return result + # 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: + f.write(content) + + if password: + # perform kinit -c ccache_name -l 1h principal + res = kinit_password(principal, password, ccache_name, + krb5conf_name) + if res: + result['failed'] = True + result['msg'] = 'kinit %s with password failed' % principal + 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' % (principal, keytab) + 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) + run_cmd(['/usr/bin/kdestroy', '-c', tmp_ccache]) diff --git a/action_plugins/ipahost.pyc b/action_plugins/ipahost.pyc deleted file mode 100644 index 5ef86f653d89033f779da0dc8329b5e900761cf5..0000000000000000000000000000000000000000 Binary files a/action_plugins/ipahost.pyc and /dev/null differ diff --git a/inventory/hosts b/inventory/hosts index a3a3ccf4a0645b122f27f5663999f2f5858207ac..552db14baa85aca610651407fc42fde601e6cdff 100644 --- a/inventory/hosts +++ b/inventory/hosts @@ -9,7 +9,13 @@ ipaclient_domain=ipadomain.com ipaclient_realm=IPADOMAIN.COM ipaclient_server=ipaserver.ipadomain.com ipaclient_extraargs=[ '--kinit-attempts=3', '--mkhomedir'] +# if neither ipaclient_password nor ipaclient_keytab is defined, +# the enrollement will create a OneTime Password and enroll with this OTP +# In this case ipaserver_password or ipaserver_keytab is required +#ipaclient_principal=admin +#ipaclient_password=SecretPassword123 +#ipaclient_keytab=/tmp/krb5.keytab +ipaserver_principal=admin +#ipaserver_password=SecretPassword123 +ipaserver_keytab=files/admin.keytab -[ipaservers:vars] -ipa_admin=admin -ipa_password=MySecretPassword123 diff --git a/library/ipa_facts.py b/library/ipa_facts.py new file mode 100644 index 0000000000000000000000000000000000000000..625387fdf30807170a3a50c37e534ba92809eb45 --- /dev/null +++ b/library/ipa_facts.py @@ -0,0 +1,175 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +import os +import re +import six +from six.moves.configparser import RawConfigParser + +from ansible.module_utils.basic import AnsibleModule + +try: + from ipalib import api +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 +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('^\s*\[ntpd\]\s*$') + + try: + with open(SERVER_SYSRESTORE_STATE) as f: + for line in f.readlines(): + if ntpd_conf_section.match(line): + return True + 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('^\s*dyndb\s+"ipa"\s+"[^"]+"\s+{$') + + try: + with open(NAMED_CONF) as f: + 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' } + assert subsystem in available_subsystems + + 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: + from ipapython import version + 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 + if part.startswith('dev') 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 = dict(), + supports_check_mode=True + ) + + # The module does not change anything, meaning that + # check mode is supported + + ipa_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(): + ipa_facts['configured']['client'] = True + + ipa_facts['version'] = get_ipa_version() + for key,value in six.iteritems(get_ipa_conf()): + ipa_facts[key] = value + + if HAS_IPASERVER: + if is_server_configured(): + ipa_facts['configured']['server'] = True + ipa_facts['configured']['dns'] = is_dns_configured() + ipa_facts['configured']['ca'] = is_ca_configured() + ipa_facts['configured']['kra'] = is_kra_configured() + ipa_facts['configured']['ntpd'] = is_ntpd_configured() + + module.exit_json( + changed=False, + ansible_facts=dict(ipa=ipa_facts) + ) + +if __name__ == '__main__': + main() diff --git a/library/ipaclient.py b/library/ipaclient.py index 37bd9c5b77832104331dd2daddff27dcd1a26e3b..a184066975db834f8f0ff20e9a3d27f056c2a661 100644 --- a/library/ipaclient.py +++ b/library/ipaclient.py @@ -151,7 +151,10 @@ def get_ipa_conf(): parser.read(paths.IPA_DEFAULT_CONF) result = dict() for item in ['basedn', 'realm', 'domain', 'server', 'host', 'xmlrpc_uri']: - value = parser.get('global', item) + if parser.has_option('global', item): + value = parser.get('global', item) + else: + value = None if value: result[item] = value @@ -251,6 +254,7 @@ def ensure_ipa_client(module): if keytab: cmd.append("--keytab") cmd.append(keytab) + cmd.append("-d") if otp: cmd.append("--password") cmd.append(otp) diff --git a/library/ipahost.py b/library/ipahost.py index c4914d610a746497860570383367c09ab7f2d62b..08305fc19449e5bd324d29aa7f333fd073be408a 100644 --- a/library/ipahost.py +++ b/library/ipahost.py @@ -36,7 +36,7 @@ description: options: principal: description: Kerberos principal used to manage the host - required: false + required: true default: admin password: description: Password for the kerberos principal @@ -44,6 +44,10 @@ options: keytab: description: Keytab file containing the Kerberos principal and encrypted key required: false + lifetime: + description: Sets the default lifetime for initial ticket requests + required: false + default: 1h fqdn: description: the fully-qualified hostname of the host to add/modify/remove required: true @@ -251,9 +255,10 @@ def main(): """ module = AnsibleModule( argument_spec=dict( - keytab = dict(required=False, type='path'), + #keytab = dict(required=False, type='path'), principal = dict(default='admin'), - password = dict(required=False, no_log=True), + #password = dict(required=False, no_log=True), + ccache = dict(required=False, type='path'), fqdn = dict(required=True), certificates = dict(required=False, type='list'), sshpubkey= dict(required=False), @@ -261,27 +266,21 @@ def main(): random = dict(default=False, type='bool'), state = dict(default='present', choices=[ 'present', 'absent' ]), ), - required_one_of=[ [ 'password', 'keytab'], ], - mutually_exclusive=[ [ 'password', 'keytab' ], ], + #mutually_exclusive=[['password','keytab']], + #required_one_of=[['[password','keytab']], supports_check_mode=True, ) principal = module.params.get('principal', 'admin') password = module.params.get('password') keytab = module.params.get('keytab') + ccache = module.params.get('ccache') 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 - os.environ['KRB5CCNAME'] = ccache_name cfg = dict( context='ansible_module', confdir=paths.ETC_IPA, diff --git a/roles/ipaclient/tasks/install.yml b/roles/ipaclient/tasks/install.yml index cc6ce0b000ead420de8fe9bcc9a645e05e3a2ace..a05aee3b3d56004abf29be82bfcea14c8da19c7c 100644 --- a/roles/ipaclient/tasks/install.yml +++ b/roles/ipaclient/tasks/install.yml @@ -1,6 +1,32 @@ --- # tasks file for ipaclient +# The following block is executed when using OTP to enroll IPA client +# ie when neither ipaclient_password not ipaclient_keytab is set +# It connects to ipaserver and add the host with --random option in order +# to create a OneTime Password +- block: + - name: Install - Get a One-Time Password for client enrollment + ipahost: + state: present + principal: "{{ ipaserver_principal | default('admin') }}" + password: "{{ ipaserver_password | default(omit) }}" + keytab: "{{ ipaserver_keytab | default(omit) }}" + fqdn: "{{ ansible_fqdn }}" + lifetime: "{{ ipaserver_lifetime | default(omit) }}" + random: True + register: ipahost_output + # If the host is already enrolled, this command will exit on error + # The error can be ignored + failed_when: ipahost_output|failed and "Password cannot be set on enrolled host" not in ipahost_output.msg + delegate_to: "{{ groups.ipaservers[0] }}" + + - name: Install - Store the previously obtained OTP + set_fact: + ipaclient_otp: "{{ipahost_output.host.randompassword if ipahost_output.host is defined else 'dummyotp' }}" + + when: ipaclient_password is not defined and ipaclient_keytab is not defined + - name: Install - Install IPA client package package: name: "{{ ipaclient_package }}" @@ -9,11 +35,11 @@ - 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 }}" + domain: "{{ ipaclient_domain | default(omit) }}" + realm: "{{ ipaclient_realm | default(omit) }}" + server: "{{ ipaclient_server | default(omit) }}" + principal: "{{ ipaclient_principal | default(omit) }}" + password: "{{ ipaclient_password | default(omit) }}" + keytab: "{{ ipaclient_keytab | default(omit) }}" + otp: "{{ ipaclient_otp | default(omit) }}" + extra_args: "{{ ipaclient_extraargs | default(omit) }}" diff --git a/roles/ipaclient/vars/default.yml b/roles/ipaclient/vars/default.yml index a0e63eaf291203dcfae8404d0e863c94679bb936..eb675d4bf87f32eb30fc56a444447e49a8fbb016 100644 --- a/roles/ipaclient/vars/default.yml +++ b/roles/ipaclient/vars/default.yml @@ -1,3 +1,3 @@ # defaults file for ipaclient -# defaults/fedora.yml +# vars/default.yml ipaclient_package: freeipa-client diff --git a/roles/ipaclient/vars/rhel.yml b/roles/ipaclient/vars/rhel.yml index c36d57dd11817a361bea721505fd779278629013..76c7a343fbd3632906c57f2ff7ed07e4e265050f 100644 --- a/roles/ipaclient/vars/rhel.yml +++ b/roles/ipaclient/vars/rhel.yml @@ -1,4 +1,4 @@ # defaults file for ipaclient -# defaults/rhel.yml +# vars/rhel.yml ipaclient_package: ipa-client diff --git a/site.yml b/site.yml index 43d5bcc4356ed3401df12dda1a10fc263fcd4136..18a4255a1a4971aaab79a5c7ddae355146e49fd4 100644 --- a/site.yml +++ b/site.yml @@ -3,17 +3,6 @@ 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 }}"