mirror of
https://github.com/freeipa/ansible-freeipa.git
synced 2026-05-14 13:32:10 +00:00
ipaclient: No kinit on controller for deployment using OTP
The generation of the OTP for client deployment is now completely happening on the first of the given or detected servers with delegate_to. The module ipaclient_get_otp has been replaced by a new module using code from ipahost module and module_utils ansible_freeipa_module. The action plugin ipaclient_get_otp has been removed and with this also ipaclient_get_facts. If an admin keytab is used instead of an admin password, it is copied to the server as a temporary file to enable the OTP generation. The temporary file is removed again after using the ipaclient_get_otp module. The utils script build-galaxy-release.sh has been updated to not copy the ipaclient action plugin to the global plugins folder of the collection. This change is import for the use of the ipaclient role with AAP as only the base environment is sufficient now. The ipaclient README and also the global README have been updated as kinit is not needed anymore on the controller for OTP. Fixes #903 (Allow the use of principals other than admin when using ipaadmin_keytab)
This commit is contained in:
@@ -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)
|
||||
'''
|
||||
- Thomas Woerner (@t-woerner)
|
||||
"""
|
||||
|
||||
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
|
||||
'''
|
||||
EXAMPLES = """
|
||||
"""
|
||||
|
||||
RETURN = '''
|
||||
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
|
||||
description: The generated random password
|
||||
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
|
||||
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
|
||||
|
||||
: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.
|
||||
|
||||
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):
|
||||
"""
|
||||
Ensure 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)
|
||||
|
||||
# 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):
|
||||
"""
|
||||
Ensure 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)
|
||||
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))
|
||||
|
||||
module.exit_json(changed=True)
|
||||
os.environ["KRB5CCNAME"] = ccache_name
|
||||
return ccache_dir, ccache_name
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
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.
|
||||
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
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.
|
||||
|
||||
"""
|
||||
return api.Command[command](name, **args)
|
||||
|
||||
|
||||
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:
|
||||
_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)
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
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()
|
||||
hostname = module_params_get(module, "hostname")
|
||||
|
||||
try:
|
||||
result = api.Command.host_show(fqdn, all=True)
|
||||
host = result['result']
|
||||
except errors.NotFound:
|
||||
host = None
|
||||
exit_args = {}
|
||||
|
||||
if state in ['present', 'disabled']:
|
||||
ensure_host_present(module, api, host)
|
||||
elif state == 'absent':
|
||||
ensure_host_absent(module, api, host)
|
||||
# Connect to IPA API
|
||||
with ipa_connect(module, ipaadmin_principal, ipaadmin_password,
|
||||
ipaadmin_keytab):
|
||||
res_show = host_show(module, hostname)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
module.exit_json(changed=False, host=host)
|
||||
exit_args["randompassword"] = result['result']['randompassword']
|
||||
|
||||
module.exit_json(changed=True, host=exit_args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user