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:
Thomas Woerner
2022-11-21 14:44:09 +01:00
parent 9423eb81b7
commit 624e0d3435
7 changed files with 262 additions and 831 deletions

View File

@@ -1,282 +0,0 @@
# -*- coding: utf-8 -*-
# Authors:
# Thomas Woerner <twoerner@redhat.com>
#
# Based on ipa-client-install code
#
# Copyright (C) 2018-2022 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 <http://www.gnu.org/licenses/>.
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
DOCUMENTATION = """
---
module: ipaclient_get_facts
short_description: Get facts about IPA client and server configuration.
description: Get facts about IPA client and server configuration.
author:
- Thomas Woerner (@t-woerner)
"""
EXAMPLES = """
"""
RETURN = """
ipa:
description: IPA configuration
returned: always
type: complex
contains:
packages:
description: IPA lib and server bindings
type: dict
returned: always
contains:
ipalib:
description: Whether ipalib.api binding could be imported.
type: bool
returned: always
ipaserver:
description: Whether ipaserver binding could be imported.
type: bool
returned: always
configured:
description: IPA components
type: dict
returned: always
contains:
client:
description: Whether client is configured
type: bool
returned: always
server:
description: Whether server is configured
type: bool
returned: always
dns:
description: Whether dns is configured
type: bool
returned: always
ca:
description: Whether ca is configured
type: bool
returned: always
kra:
description: Whether kra is configured
type: bool
returned: always
ntpd:
description: Whether ntpd is configured
type: bool
returned: always
"""
import os
import re
from ansible.module_utils import six
try:
from ansible.module_utils.six.moves.configparser import RawConfigParser
except ImportError:
from ConfigParser import RawConfigParser
from ansible.module_utils.basic import AnsibleModule
# pylint: disable=unused-import
try:
from ipalib import api # noqa: F401
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 # noqa: F401
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(r'^\s*\[ntpd\]\s*$')
try:
# pylint: disable=invalid-name
with open(SERVER_SYSRESTORE_STATE) as f:
for line in f.readlines():
if ntpd_conf_section.match(line):
return True
# pylint: enable=invalid-name
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(r'^\s*dyndb\s+"ipa"\s+"[^"]+"\s+{$')
try:
with open(NAMED_CONF) as f: # pylint: disable=invalid-name
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'}
if subsystem not in available_subsystems:
raise AssertionError("Subsystem '%s' not available" % subsystem)
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:
# pylint: disable=import-outside-toplevel
from ipapython import version
# pylint: enable=import-outside-toplevel
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
# 4.6.90.pre2
if part.startswith('dev') or part.startswith('pre') 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={},
supports_check_mode=True
)
# The module does not change anything, meaning that
# check mode is supported
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():
facts['configured']['client'] = True
facts['version'] = get_ipa_version()
for key, value in six.iteritems(get_ipa_conf()):
facts[key] = value
if HAS_IPASERVER:
if is_server_configured():
facts['configured']['server'] = True
facts['configured']['dns'] = is_dns_configured()
facts['configured']['ca'] = is_ca_configured()
facts['configured']['kra'] = is_kra_configured()
facts['configured']['ntpd'] = is_ntpd_configured()
module.exit_json(
changed=False,
ansible_facts=dict(ipa=facts)
)
if __name__ == '__main__':
main()

View File

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