#!/usr/bin/python # Copyright 2016 Red Hat, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import getpass import logging import os import pwd import six import socket import string import sys import tempfile from ipalib import api from ipalib import errors from ipalib import x509 from ipapython import certdb from ipapython import ipaldap from ipapython.ipautil import CalledProcessError from ipapython.ipautil import ipa_generate_password from ipapython.ipautil import realm_to_suffix from ipapython.ipautil import run from ipapython.ipautil import user_input from ipapython.ipautil import write_tmp_file from ipapython import version from novajoin.errors import ConfigurationError try: from ipalib import certstore except ImportError: # The import moved in freeIPA 4.5.0 from ipalib.install import certstore try: from ipapython.ipautil import kinit_password except ImportError: # The import moved in freeIPA 4.5.0 from ipalib.install.kinit import kinit_password if version.NUM_VERSION >= 40500: from cryptography.hazmat.primitives import serialization import nss.nss as nss logger = logging.getLogger() allowed_chars = string.letters + string.digits KRB5_CONF_TMPL = """ includedir /var/lib/sss/pubconf/krb5.include.d/ [libdefaults] default_realm = $REALM dns_lookup_realm = false dns_lookup_kdc = false rdns = false ticket_lifetime = 24h forwardable = yes udp_preference_limit = 0 default_ccache_name = KEYRING:persistent:%{uid} [realms] $REALM = { kdc = $MASTER:88 master_kdc = $MASTER:88 admin_server = $MASTER:749 default_domain = $DOMAIN } [domain_realm] .$DOMAIN = $REALM $DOMAIN = $REALM """ class NovajoinRole(object): """One-stop shopping for creating the IPA permissions, privilege and role. Assumes that ipalib is imported and initialized and an RPC context already exists. """ def __init__(self, keytab='/etc/novajoin/krb5.keytab', user='novajoin', hostname=None): self.keytab = keytab self.user = user if not hostname: self.hostname = self._get_fqdn() else: self.hostname = hostname self.service = u'nova/%s' % self.hostname self.ccache_name = None def _get_fqdn(self): """Try to determine the fully-qualfied domain name of this box""" fqdn = "" try: fqdn = socket.getfqdn() except Exception: # pylint: disable=broad-except try: # assume it is in the IPA domain if it comes back # not fully-qualified fqdn = socket.gethostname() # pylint: disable=no-member fqdn = fqdn + '.' + api.env.domain except Exception: # pylint: disable=broad-except fqdn = "" return fqdn def write_tmp_krb5_conf(self, opts, filename): options = {'MASTER': opts.server, 'DOMAIN': opts.domain, 'REALM': opts.realm} template = string.Template(KRB5_CONF_TMPL) text = template.substitute(options) with open(filename, 'w+') as f: f.write(text) def create_krb5_conf(self, opts): (krb_fd, krb_name) = tempfile.mkstemp() os.close(krb_fd) self.write_tmp_krb5_conf(opts, krb_name) return krb_name def _get_ca_certs(self, server, realm): basedn = realm_to_suffix(realm) if version.NUM_VERSION >= 40500: ldap_uri = ipaldap.get_ldap_uri(server) try: conn = ipaldap.LDAPClient(ldap_uri, sasl_nocanon=True) conn.gssapi_bind() certs = certstore.get_ca_certs(conn, basedn, realm, False) except Exception as e: raise ConfigurationError("get_ca_certs() error: %s" % e) else: try: conn = ipaldap.IPAdmin(server, sasl_nocanon=True) conn.do_sasl_gssapi_bind() certs = certstore.get_ca_certs(conn, basedn, realm, False) except Exception as e: raise ConfigurationError("get_ca_certs() error: %s" % e) if version.NUM_VERSION < 40600: certs = [x509.load_certificate(c[0], x509.DER) for c in certs if c[2] is not False] else: certs = [c[0] for c in certs if c[2] is not False] return certs def create_nssdb(self, server, realm): """Retrieve IPA CA certificate chain to NSS database. Retrieve the CA cert chain from IPA and add it to a temporary NSS database and return the path to it. NOTE: For IPA v4.4.0. """ nss.nss_init_nodb() nss_db = certdb.NSSDatabase() ca_certs = self._get_ca_certs(server, realm) ca_certs = [cert.der_data for cert in ca_certs] # Add CA certs to a temporary NSS database try: pwd_file = write_tmp_file(ipa_generate_password()) nss_db.create_db(pwd_file.name) for i, cert in enumerate(ca_certs): nss_db.add_cert(cert, 'CA certificate %d' % (i + 1), 'C,,') except CalledProcessError: raise ConfigurationError( 'Failed to add CA to temporary NSS database.') return nss_db def create_cafile(self, server, realm): """Retrieve IPA CA certificate chain to a file Retrieve the CA cert chain from IPA and add it to a temporary file and return the name of the file. The caller is responsible for removing the temporary file. NOTE: For IPA v4.5.0+ """ (cafile_fd, cafile_name) = tempfile.mkstemp() os.close(cafile_fd) ca_certs = self._get_ca_certs(server, realm) if version.NUM_VERSION < 40600: ca_certs = [cert.public_bytes(serialization.Encoding.PEM) for cert in ca_certs] x509.write_certificate_list(ca_certs, cafile_name) return cafile_name def kinit(self, principal, realm, password, config=None): ccache_dir = tempfile.mkdtemp(prefix='krbcc') self.ccache_name = os.path.join(ccache_dir, 'ccache') current_ccache = os.environ.get('KRB5CCNAME') os.environ['KRB5CCNAME'] = self.ccache_name if principal.find('@') == -1: # pylint: disable=no-member principal = '%s@%s' % (principal, realm) try: kinit_password(principal, password, self.ccache_name, config=config) except RuntimeError as e: raise ConfigurationError("Kerberos authentication failed: %s" % e) finally: if current_ccache: os.environ['KRB5CCNAME'] = current_ccache return ccache_dir def _call_ipa(self, command, args, kw): """Call into the IPA API. Duplicates are ignored to be idempotent. Other errors are ignored implitly because they are encapsulated in the result for some calls. """ try: api.Command[command](args, **kw) except errors.DuplicateEntry: pass except Exception as e: # pylint: disable=broad-except logger.error("Unhandled exception: %s", e) def _add_permissions(self): logging.debug('Add permissions') self._call_ipa(u'permission_add', u'Modify host password', {'ipapermright': u'write', 'type': u'host', 'attrs': u'userpassword'}) self._call_ipa(u'permission_add', u'Write host certificate', {'ipapermright': u'write', 'type': u'host', 'attrs': u'usercertificate'}) self._call_ipa(u'permission_add', u'Modify host userclass', {'ipapermright': u'write', 'type': u'host', 'attrs': u'userclass'}) self._call_ipa(u'permission_add', u'Modify service managedBy attribute', {'ipapermright': u'write', 'type': u'service', 'attrs': u'managedby'}) def _add_privileges(self): logging.debug('Add privileges') self._call_ipa(u'privilege_add', u'Nova Host Management', {'description': u'Nova Host Management'}) self._call_ipa(u'privilege_add_permission', u'Nova Host Management', {u'permission': [ u'System: add hosts', u'System: remove hosts', u'modify host password', u'modify host userclass', u'modify hosts', u'modify service managedBy attribute', u'System: Add krbPrincipalName to a Host', u'System: Add Services', u'System: Remove Services', u'System: revoke certificate', u'System: manage host keytab', u'System: write host certificate', u'System: retrieve certificates from the ca', u'System: modify services', u'System: manage service keytab', u'System: read dns entries', u'System: remove dns entries', u'System: add dns entries', u'System: update dns entries', u'Retrieve Certificates from the CA', u'Revoke Certificate']}) def _add_role(self): logging.debug('Add role') self._call_ipa(u'role_add', u'Nova Host Manager', {'description': u'Nova Host Manager'}) self._call_ipa(u'role_add_privilege', u'Nova Host Manager', {'privilege': u'Nova Host Management'}) self._call_ipa(u'role_add_member', u'Nova Host Manager', {u'service': self.service}) def _add_host(self, filename): logging.debug('Add host %s', self.hostname) if version.NUM_VERSION >= 40500: otp = ipa_generate_password(special=None) else: otp = ipa_generate_password(allowed_chars) self._call_ipa(u'host_add', six.text_type(self.hostname), {'description': u'Undercloud host', 'userpassword': six.text_type(otp), 'force': True}) if filename: with open(filename, "w") as fd: fd.write("%s\n" % otp) else: return otp def _add_service(self): logging.debug('Add service %s', self.service) self._call_ipa(u'service_add', self.service, {'force': True}) def _get_keytab(self): logging.debug('Getting keytab %s for %s', self.keytab, self.service) if self.ccache_name: current_ccache = os.environ.get('KRB5CCNAME') os.environ['KRB5CCNAME'] = self.ccache_name else: current_ccache = None try: if os.path.exists(self.keytab): os.unlink(self.keytab) except OSError as e: sys.exit('Could not remove %s: %s' % (self.keytab, e)) try: run(['ipa-getkeytab', '-s', api.env.server, # pylint: disable=no-member '-p', self.service, '-k', self.keytab]) finally: if current_ccache: os.environ['KRB5CCNAME'] = current_ccache # s/b already validated user = pwd.getpwnam(self.user) os.chown(self.keytab, user.pw_uid, user.pw_gid) os.chmod(self.keytab, 0o600) def configure_ipa(self, precreate, otp_filename=None): otp = None if precreate: otp = self._add_host(otp_filename) self._add_service() if not precreate: self._get_keytab() self._add_permissions() self._add_privileges() self._add_role() if otp: print(otp) def ipa_options(parser): parser.add_argument('--debug', help='Additional logging output', action="store_true", default=False) parser.add_argument('--no-kinit', help='Assume the user has already done a kinit', action="store_true", default=False) parser.add_argument('--user', help='User that nova services run as', default='nova') parser.add_argument('--principal', dest='principal', default='admin', help='principal to use to setup IPA integration') parser.add_argument('--password', dest='password', help='password for the principal') parser.add_argument('--password-file', dest='passwordfile', help='path to file containing password for ' 'the principal') parser.add_argument('--precreate', default=False, help='Pre-create the IPA host with an OTP', action="store_true") noconfig = parser.add_argument_group('Pre-create options') noconfig.add_argument('--server', dest='server', help='IPA server') noconfig.add_argument('--realm', dest='realm', help='IPA realm name') noconfig.add_argument('--domain', dest='domain', help='IPA domain name') noconfig.add_argument('--hostname', dest='hostname', help='Hostname of IPA host to create') noconfig.add_argument('--otp-file', dest='otp_filename', help='File to write OTP to instead of stdout') return parser def validate_options(opts): if opts.precreate and not os.path.exists('/etc/ipa/default.conf'): if not opts.hostname: raise ConfigurationError('hostname is required') if not opts.domain: raise ConfigurationError('IPA domain is required') if not opts.realm: raise ConfigurationError('IPA realm is required') if not opts.server: raise ConfigurationError('IPA server is required') if opts.no_kinit: return if not opts.principal: opts.principal = user_input("IPA admin user", "admin", allow_empty=False) if opts.passwordfile: try: with open(opts.passwordfile) as f: opts.password = f.read() except IOError as e: raise ConfigurationError('Unable to read password file: %s' % e) if not opts.password: try: opts.password = getpass.getpass("Password for %s: " % opts.principal) except EOFError: opts.password = None if not opts.password: raise ConfigurationError('Password must be provided.') if not opts.precreate: try: pwd.getpwnam(opts.user) except KeyError: raise ConfigurationError('User: %s not found on the system' % opts.user)