novajoin/novajoin/configure_ipa.py

448 lines
16 KiB
Python

#!/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.ascii_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)