Skip to content
Snippets Groups Projects
Commit 38d72233 authored by Florence Blanc-Renaud's avatar Florence Blanc-Renaud
Browse files

Modify ipahost module: the authentication is done locally on the controller

node and the credential cache is copied to the managed node

ipahost module is also using facts gathered from the server to find the
domain and realm.
parent 09f45e4a
No related branches found
No related tags found
No related merge requests found
...@@ -17,42 +17,190 @@ ...@@ -17,42 +17,190 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import gssapi
import os import os
import shutil
import subprocess
import tempfile
from jinja2 import Template
from ansible.errors import AnsibleError from ansible.errors import AnsibleError
from ansible.module_utils._text import to_native from ansible.module_utils._text import to_native
from ansible.plugins.action import ActionBase 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): class ActionModule(ActionBase):
def run(self, tmp=None, task_vars=None): 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 ipa* commands can either provide a password or a keytab file
in order to authenticate on the managed node with Kerberos. in order to authenticate on the managed node with Kerberos.
When a keytab is provided, it needs to be copied from the control The module is using these credentials to obtain a TGT locally on the
node to the managed node. control node:
This Action Module performs the copy when needed. - 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: if task_vars is None:
task_vars = dict() task_vars = dict()
result = super(ActionModule, self).run(tmp, task_vars) result = super(ActionModule, self).run(tmp, task_vars)
principal = self._task.args.get('principal', None)
keytab = self._task.args.get('keytab', None) keytab = self._task.args.get('keytab', None)
password = self._task.args.get('password', 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['failed'] = True
result['msg'] = "keytab or password is required" result['msg'] = "keytab or password is required"
return result return result
# If password is supplied, just need to execute the module if not principal:
result['failed'] = True
result['msg'] = "principal is required"
return result
data = self._execute_module(module_name='ipa_facts', module_args=dict(),
task_vars=None)
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:
f.write(content)
if password: if password:
result.update(self._execute_module(task_vars=task_vars)) # 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 return result
# Password not supplied, need to transfer the keytab file else:
# Password not supplied, need to use the keytab file
# Check if the source keytab exists # Check if the source keytab exists
try: try:
keytab = self._find_needle('files', keytab) keytab = self._find_needle('files', keytab)
...@@ -60,18 +208,35 @@ class ActionModule(ActionBase): ...@@ -60,18 +208,35 @@ class ActionModule(ActionBase):
result['failed'] = True result['failed'] = True
result['msg'] = to_native(e) result['msg'] = to_native(e)
return result 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 # Create the remote tmp dir
tmp = self._make_tmp_path() tmp = self._make_tmp_path()
tmp_keytab = self._connection._shell.join_path( tmp_ccache = self._connection._shell.join_path(
tmp, os.path.basename(keytab)) tmp, os.path.basename(ccache_name))
self._transfer_file(keytab, tmp_keytab)
self._fixup_perms2((tmp, tmp_keytab)) # 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 = self._task.args.copy()
new_module_args.update(dict(keytab=tmp_keytab)) 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 # Execute module
result.update(self._execute_module(module_args=new_module_args, task_vars=task_vars)) result.update(self._execute_module(module_args=new_module_args,
self._remove_tmp_path(tmp) task_vars=task_vars))
return result return result
finally:
# delete the local temp directory
shutil.rmtree(local_temp_dir, ignore_errors=True)
run_cmd(['/usr/bin/kdestroy', '-c', tmp_ccache])
File deleted
...@@ -9,7 +9,13 @@ ipaclient_domain=ipadomain.com ...@@ -9,7 +9,13 @@ ipaclient_domain=ipadomain.com
ipaclient_realm=IPADOMAIN.COM ipaclient_realm=IPADOMAIN.COM
ipaclient_server=ipaserver.ipadomain.com ipaclient_server=ipaserver.ipadomain.com
ipaclient_extraargs=[ '--kinit-attempts=3', '--mkhomedir'] 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
#!/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()
...@@ -151,7 +151,10 @@ def get_ipa_conf(): ...@@ -151,7 +151,10 @@ def get_ipa_conf():
parser.read(paths.IPA_DEFAULT_CONF) parser.read(paths.IPA_DEFAULT_CONF)
result = dict() result = dict()
for item in ['basedn', 'realm', 'domain', 'server', 'host', 'xmlrpc_uri']: for item in ['basedn', 'realm', 'domain', 'server', 'host', 'xmlrpc_uri']:
if parser.has_option('global', item):
value = parser.get('global', item) value = parser.get('global', item)
else:
value = None
if value: if value:
result[item] = value result[item] = value
...@@ -251,6 +254,7 @@ def ensure_ipa_client(module): ...@@ -251,6 +254,7 @@ def ensure_ipa_client(module):
if keytab: if keytab:
cmd.append("--keytab") cmd.append("--keytab")
cmd.append(keytab) cmd.append(keytab)
cmd.append("-d")
if otp: if otp:
cmd.append("--password") cmd.append("--password")
cmd.append(otp) cmd.append(otp)
......
...@@ -36,7 +36,7 @@ description: ...@@ -36,7 +36,7 @@ description:
options: options:
principal: principal:
description: Kerberos principal used to manage the host description: Kerberos principal used to manage the host
required: false required: true
default: admin default: admin
password: password:
description: Password for the kerberos principal description: Password for the kerberos principal
...@@ -44,6 +44,10 @@ options: ...@@ -44,6 +44,10 @@ options:
keytab: keytab:
description: Keytab file containing the Kerberos principal and encrypted key description: Keytab file containing the Kerberos principal and encrypted key
required: false required: false
lifetime:
description: Sets the default lifetime for initial ticket requests
required: false
default: 1h
fqdn: fqdn:
description: the fully-qualified hostname of the host to add/modify/remove description: the fully-qualified hostname of the host to add/modify/remove
required: true required: true
...@@ -251,9 +255,10 @@ def main(): ...@@ -251,9 +255,10 @@ def main():
""" """
module = AnsibleModule( module = AnsibleModule(
argument_spec=dict( argument_spec=dict(
keytab = dict(required=False, type='path'), #keytab = dict(required=False, type='path'),
principal = dict(default='admin'), 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), fqdn = dict(required=True),
certificates = dict(required=False, type='list'), certificates = dict(required=False, type='list'),
sshpubkey= dict(required=False), sshpubkey= dict(required=False),
...@@ -261,27 +266,21 @@ def main(): ...@@ -261,27 +266,21 @@ def main():
random = dict(default=False, type='bool'), random = dict(default=False, type='bool'),
state = dict(default='present', choices=[ 'present', 'absent' ]), 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, supports_check_mode=True,
) )
principal = module.params.get('principal', 'admin') principal = module.params.get('principal', 'admin')
password = module.params.get('password') password = module.params.get('password')
keytab = module.params.get('keytab') keytab = module.params.get('keytab')
ccache = module.params.get('ccache')
fqdn = unicode(module.params.get('fqdn')) fqdn = unicode(module.params.get('fqdn'))
state = module.params.get('state') state = module.params.get('state')
try: try:
ccache_dir = tempfile.mkdtemp(prefix='krbcc') os.environ['KRB5CCNAME']=ccache
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( cfg = dict(
context='ansible_module', context='ansible_module',
confdir=paths.ETC_IPA, confdir=paths.ETC_IPA,
......
--- ---
# tasks file for ipaclient # 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 - name: Install - Install IPA client package
package: package:
name: "{{ ipaclient_package }}" name: "{{ ipaclient_package }}"
...@@ -9,11 +35,11 @@ ...@@ -9,11 +35,11 @@
- name: Install - Configure IPA client - name: Install - Configure IPA client
ipaclient: ipaclient:
state: present state: present
domain: "{{ ipaclient_domain }}" domain: "{{ ipaclient_domain | default(omit) }}"
realm: "{{ ipaclient_realm }}" realm: "{{ ipaclient_realm | default(omit) }}"
server: "{{ ipaclient_server }}" server: "{{ ipaclient_server | default(omit) }}"
principal: "{{ ipaclient_principal }}" principal: "{{ ipaclient_principal | default(omit) }}"
password: "{{ ipaclient_password }}" password: "{{ ipaclient_password | default(omit) }}"
keytab: "{{ ipaclient_keytab }}" keytab: "{{ ipaclient_keytab | default(omit) }}"
otp: "{{ ipaclient_otp }}" otp: "{{ ipaclient_otp | default(omit) }}"
extra_args: "{{ ipaclient_extraargs }}" extra_args: "{{ ipaclient_extraargs | default(omit) }}"
# defaults file for ipaclient # defaults file for ipaclient
# defaults/fedora.yml # vars/default.yml
ipaclient_package: freeipa-client ipaclient_package: freeipa-client
# defaults file for ipaclient # defaults file for ipaclient
# defaults/rhel.yml # vars/rhel.yml
ipaclient_package: ipa-client ipaclient_package: ipa-client
...@@ -3,17 +3,6 @@ ...@@ -3,17 +3,6 @@
hosts: ipaclients hosts: ipaclients
become: true 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: roles:
- role: ipaclient - role: ipaclient
state: present state: present
ipaclient_otp: "{{ ipahost.host.randompassword }}"
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment