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.
This commit is contained in:
Florence Blanc-Renaud
2017-08-10 16:54:44 +02:00
parent 09f45e4acd
commit 38d7223376
10 changed files with 430 additions and 66 deletions

View File

@@ -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))
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:
if not principal:
result['failed'] = True
result['msg'] = to_native(e)
result['msg'] = "principal is required"
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))
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
new_module_args = self._task.args.copy()
new_module_args.update(dict(keytab=tmp_keytab))
items = principal.split('@')
if len(items) < 2:
principal = str('%s@%s' % (principal, realm))
# Execute module
result.update(self._execute_module(module_args=new_module_args, task_vars=task_vars))
self._remove_tmp_path(tmp)
return result
# 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:
# 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])

Binary file not shown.

View File

@@ -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

175
library/ipa_facts.py Normal file
View File

@@ -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()

View File

@@ -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)

View File

@@ -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')
os.environ['KRB5CCNAME']=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,

View File

@@ -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) }}"

View File

@@ -1,3 +1,3 @@
# defaults file for ipaclient
# defaults/fedora.yml
# vars/default.yml
ipaclient_package: freeipa-client

View File

@@ -1,4 +1,4 @@
# defaults file for ipaclient
# defaults/rhel.yml
# vars/rhel.yml
ipaclient_package: ipa-client

View File

@@ -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 }}"