From 3c2b0fc28ae21c7e4b26961e28e2eb0ba0559d29 Mon Sep 17 00:00:00 2001 From: Jan Cholasta Date: Wed, 7 Dec 2011 02:50:31 -0500 Subject: Add support for SSH public keys to user and host objects. This patch adds a new multivalue param "sshpubkey" for specifying SSH public keys to both user and host objects. The accepted value is base64-encoded public key blob as specified in RFC4253, section 6.6. Additionaly, host commands automatically update DNS SSHFP records when requested by user. https://fedorahosted.org/freeipa/ticket/754 --- API.txt | 13 +++-- VERSION | 2 +- ipalib/plugins/host.py | 116 +++++++++++++++++++++++++++++-------- ipalib/plugins/user.py | 37 ++++++++++-- ipalib/util.py | 32 ++++++++++ ipapython/ipautil.py | 21 +++++++ ipaserver/install/krbinstance.py | 2 +- tests/test_xmlrpc/objectclasses.py | 4 ++ 8 files changed, 192 insertions(+), 35 deletions(-) diff --git a/API.txt b/API.txt index 9c3ad9e99..66713317c 100644 --- a/API.txt +++ b/API.txt @@ -1657,7 +1657,7 @@ output: Output('notmatched', (, , ), output: Output('error', (, , ), None) output: Output('value', , None) command: host_add -args: 1,15,3 +args: 1,16,3 arg: Str('fqdn', attribute=True, cli_name='hostname', maxlength=255, multivalue=False, pattern='^[a-zA-Z0-9][a-zA-Z0-9-\\.]{0,254}$', pattern_errmsg='may only include letters, numbers, and -', primary_key=True, required=True) option: Str('description', attribute=True, cli_name='desc', multivalue=False, required=False) option: Str('l', attribute=True, cli_name='locality', multivalue=False, required=False) @@ -1668,6 +1668,7 @@ option: Str('userpassword', attribute=True, cli_name='password', multivalue=Fals option: Flag('random', attribute=False, autofill=True, cli_name='random', default=False, multivalue=False, required=False) option: Bytes('usercertificate', attribute=True, cli_name='certificate', multivalue=False, required=False) option: Str('macaddress', attribute=True, cli_name='macaddress', csv=True, multivalue=True, pattern='^([a-fA-F0-9]{2}[:|\\-]?){5}[a-fA-F0-9]{2}$', pattern_errmsg='Must be of the form HH:HH:HH:HH:HH:HH, where each H is a hexadecimal character.', required=False) +option: Bytes('ipasshpubkey', attribute=True, cli_name='sshpubkey', multivalue=True, required=False) option: Flag('force', autofill=True, default=False) option: Flag('no_reverse', autofill=True, default=False) option: Str('ip_address?') @@ -1739,7 +1740,7 @@ output: ListOfEntries('result', (, ), Gettext('A list output: Output('count', , None) output: Output('truncated', , None) command: host_mod -args: 1,17,3 +args: 1,19,3 arg: Str('fqdn', attribute=True, cli_name='hostname', maxlength=255, multivalue=False, pattern='^[a-zA-Z0-9][a-zA-Z0-9-\\.]{0,254}$', pattern_errmsg='may only include letters, numbers, and -', primary_key=True, query=True, required=True) option: Str('description', attribute=True, autofill=False, cli_name='desc', multivalue=False, required=False) option: Str('l', attribute=True, autofill=False, cli_name='locality', multivalue=False, required=False) @@ -1750,11 +1751,13 @@ option: Str('userpassword', attribute=True, autofill=False, cli_name='password', option: Flag('random', attribute=False, autofill=True, cli_name='random', default=False, multivalue=False, required=False) option: Bytes('usercertificate', attribute=True, autofill=False, cli_name='certificate', multivalue=False, required=False) option: Str('macaddress', attribute=True, autofill=False, cli_name='macaddress', csv=True, multivalue=True, pattern='^([a-fA-F0-9]{2}[:|\\-]?){5}[a-fA-F0-9]{2}$', pattern_errmsg='Must be of the form HH:HH:HH:HH:HH:HH, where each H is a hexadecimal character.', required=False) +option: Bytes('ipasshpubkey', attribute=True, autofill=False, cli_name='sshpubkey', multivalue=True, required=False) option: Str('setattr*', cli_name='setattr', exclude='webui') option: Str('addattr*', cli_name='addattr', exclude='webui') option: Str('delattr*', cli_name='delattr', exclude='webui') option: Flag('rights', autofill=True, default=False) option: Str('krbprincipalname?', attribute=True, cli_name='principalname') +option: Flag('updatedns?', autofill=True, default=False) option: Flag('all', autofill=True, cli_name='all', default=False, exclude='webui') option: Flag('raw', autofill=True, cli_name='raw', default=False, exclude='webui') option: Str('version?', exclude='webui') @@ -3087,7 +3090,7 @@ output: Output('summary', (, ), None) output: Entry('result', , Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None)) output: Output('value', , None) command: user_add -args: 1,32,3 +args: 1,33,3 arg: Str('uid', attribute=True, cli_name='login', maxlength=255, multivalue=False, pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$', pattern_errmsg='may only include letters, numbers, _, -, . and $', primary_key=True, required=True) option: Str('givenname', attribute=True, cli_name='first', multivalue=False, required=True) option: Str('sn', attribute=True, cli_name='last', multivalue=False, required=True) @@ -3115,6 +3118,7 @@ option: Str('ou', attribute=True, cli_name='orgunit', multivalue=False, required option: Str('title', attribute=True, cli_name='title', multivalue=False, required=False) option: Str('manager', attribute=True, cli_name='manager', multivalue=False, required=False) option: Str('carlicense', attribute=True, cli_name='carlicense', multivalue=False, required=False) +option: Bytes('ipasshpubkey', attribute=True, cli_name='sshpubkey', multivalue=True, required=False) option: Str('setattr*', cli_name='setattr', exclude='webui') option: Str('addattr*', cli_name='addattr', exclude='webui') option: Flag('noprivate', autofill=True, cli_name='noprivate', default=False) @@ -3194,7 +3198,7 @@ output: ListOfEntries('result', (, ), Gettext('A list output: Output('count', , None) output: Output('truncated', , None) command: user_mod -args: 1,33,3 +args: 1,34,3 arg: Str('uid', attribute=True, cli_name='login', maxlength=255, multivalue=False, pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$', pattern_errmsg='may only include letters, numbers, _, -, . and $', primary_key=True, query=True, required=True) option: Str('givenname', attribute=True, autofill=False, cli_name='first', multivalue=False, required=False) option: Str('sn', attribute=True, autofill=False, cli_name='last', multivalue=False, required=False) @@ -3221,6 +3225,7 @@ option: Str('ou', attribute=True, autofill=False, cli_name='orgunit', multivalue option: Str('title', attribute=True, autofill=False, cli_name='title', multivalue=False, required=False) option: Str('manager', attribute=True, autofill=False, cli_name='manager', multivalue=False, required=False) option: Str('carlicense', attribute=True, autofill=False, cli_name='carlicense', multivalue=False, required=False) +option: Bytes('ipasshpubkey', attribute=True, autofill=False, cli_name='sshpubkey', multivalue=True, required=False) option: Str('setattr*', cli_name='setattr', exclude='webui') option: Str('addattr*', cli_name='addattr', exclude='webui') option: Str('delattr*', cli_name='delattr', exclude='webui') diff --git a/VERSION b/VERSION index a3239577f..59a606620 100644 --- a/VERSION +++ b/VERSION @@ -79,4 +79,4 @@ IPA_DATA_VERSION=20100614120000 # # ######################################################## IPA_API_VERSION_MAJOR=2 -IPA_API_VERSION_MINOR=25 +IPA_API_VERSION_MINOR=26 diff --git a/ipalib/plugins/host.py b/ipalib/plugins/host.py index 0cae656b7..682b81420 100644 --- a/ipalib/plugins/host.py +++ b/ipalib/plugins/host.py @@ -22,6 +22,8 @@ import platform import os import sys from nss.error import NSPRError +import nss.nss as nss +import netaddr from ipalib import api, errors, util from ipalib import Str, Flag, Bytes @@ -34,11 +36,9 @@ from ipalib.plugins.dns import add_forward_record from ipalib import _, ngettext from ipalib import x509 from ipalib.dn import * -from ipapython.ipautil import ipa_generate_password, CheckedIPAddress from ipalib.request import context -import base64 -import nss.nss as nss -import netaddr +from ipalib.util import validate_sshpubkey, output_sshpubkey +from ipapython.ipautil import ipa_generate_password, CheckedIPAddress, make_sshfp __doc__ = _(""" Hosts/Machines @@ -87,6 +87,9 @@ EXAMPLES: Modify information about a host: ipa host-mod --os='Fedora 12' test.example.com + Remove SSH public keys of a host and update DNS to reflect this change: + ipa host-mod --sshpubkey= --updatedns test.example.com + Disable the host Kerberos key, SSL certificate and all of its services: ipa host-disable test.example.com @@ -162,6 +165,22 @@ def remove_fwd_ptr(ipaddr, host, domain, recordtype): except errors.NotFound: pass +def update_sshfp_record(zone, record, entry_attrs): + if 'ipasshpubkey' not in entry_attrs: + return + + pubkeys = entry_attrs['ipasshpubkey'] or () + sshfps=[] + for pubkey in pubkeys: + sshfp = unicode(make_sshfp(pubkey)) + if sshfp is not None: + sshfps.append(sshfp) + + try: + api.Command['dnsrecord_mod'](zone, record, sshfprecord=sshfps) + except errors.EmptyModlist: + pass + host_output_params = ( Flag('has_keytab', label=_('Keytab'), @@ -226,6 +245,7 @@ class host(LDAPObject): 'fqdn', 'description', 'l', 'nshostlocation', 'krbprincipalname', 'nshardwareplatform', 'nsosversion', 'usercertificate', 'memberof', 'managedby', 'memberindirect', 'memberofindirect', 'macaddress', + 'sshpubkeyfp', ] uuid_attribute = 'ipauniqueid' attribute_members = { @@ -315,6 +335,15 @@ class host(LDAPObject): label=_('MAC address'), doc=_('Hardware MAC address(es) on this host'), ), + Bytes('ipasshpubkey*', validate_sshpubkey, + cli_name='sshpubkey', + label=_('Base-64 encoded SSH public key'), + flags=['no_search'], + ), + Str('sshpubkeyfp*', + label=_('SSH public key fingerprint'), + flags=['virtual_attribute', 'no_create', 'no_update', 'no_search'], + ), ) def get_dn(self, *keys, **options): @@ -452,33 +481,37 @@ class host_add(LDAPCreate): entry_attrs['usercertificate'] = cert entry_attrs['managedby'] = dn entry_attrs['objectclass'].append('ieee802device') + entry_attrs['objectclass'].append('ipasshhost') return dn def post_callback(self, ldap, dn, entry_attrs, *keys, **options): exc = None - try: - if 'ip_address' in options and dns_container_exists(ldap): + if dns_container_exists(ldap): + try: parts = keys[-1].split('.') domain = unicode('.'.join(parts[1:])) - ip = CheckedIPAddress(options['ip_address'], match_local=False) - add_forward_record(domain, parts[0], unicode(ip)) + if 'ip_address' in options: + ip = CheckedIPAddress(options['ip_address'], match_local=False) + add_forward_record(domain, parts[0], unicode(ip)) - if not options.get('no_reverse', False): - try: - prefixlen = None - if not ip.defaultnet: - prefixlen = ip.prefixlen - revzone, revname = get_reverse_zone(ip, prefixlen) - addkw = { 'ptrrecord' : keys[-1]+'.' } - api.Command['dnsrecord_add'](revzone, revname, **addkw) - except errors.EmptyModlist: - # the entry already exists and matches - pass + if not options.get('no_reverse', False): + try: + prefixlen = None + if not ip.defaultnet: + prefixlen = ip.prefixlen + revzone, revname = get_reverse_zone(ip, prefixlen) + addkw = { 'ptrrecord' : keys[-1]+'.' } + api.Command['dnsrecord_add'](revzone, revname, **addkw) + except errors.EmptyModlist: + # the entry already exists and matches + pass - del options['ip_address'] - except Exception, e: - exc = e + del options['ip_address'] + + update_sshfp_record(domain, unicode(parts[0]), entry_attrs) + except Exception, e: + exc = e if options.get('random', False): try: entry_attrs['randompassword'] = unicode(getattr(context, 'randompassword')) @@ -493,13 +526,15 @@ class host_add(LDAPCreate): set_certificate_attrs(entry_attrs) if options.get('all', False): - entry_attrs['managing'] = self.obj.get_managed_hosts(dn) + entry_attrs['managing'] = self.obj.get_managed_hosts(dn) self.obj.get_password_attributes(ldap, dn, entry_attrs) if entry_attrs['has_password']: # If an OTP is set there is no keytab, at least not one # fetched anywhere. entry_attrs['has_keytab'] = False + output_sshpubkey(ldap, dn, entry_attrs) + return dn api.register(host_add) @@ -632,6 +667,10 @@ class host_mod(LDAPUpdate): doc=_('Kerberos principal name for this host'), attribute=True, ), + Flag('updatedns?', + doc=_('Update DNS entries'), + default=False, + ), ) def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): @@ -688,6 +727,7 @@ class host_mod(LDAPUpdate): raise nsprerr entry_attrs['usercertificate'] = cert + if options.get('random'): entry_attrs['userpassword'] = ipa_generate_password() setattr(context, 'randompassword', entry_attrs['userpassword']) @@ -703,6 +743,30 @@ class host_mod(LDAPUpdate): obj_classes.append('ieee802device') entry_attrs['objectclass'] = obj_classes + if options.get('updatedns', False) and dns_container_exists(ldap): + parts = keys[-1].split('.') + domain = unicode('.'.join(parts[1:])) + result = api.Command['dnszone_find']()['result'] + match = False + for zone in result: + if domain == zone['idnsname'][0]: + match = True + break + if not match: + raise errors.NotFound( + reason=_('DNS zone %(zone)s not found') % dict(zone=domain) + ) + update_sshfp_record(domain, unicode(parts[0]), entry_attrs) + + if 'ipasshpubkey' in entry_attrs: + if 'objectclass' in entry_attrs: + obj_classes = entry_attrs['objectclass'] + else: + (_dn, _entry_attrs) = ldap.get_entry(dn, ['objectclass']) + obj_classes = entry_attrs['objectclass'] = _entry_attrs['objectclass'] + if 'ipasshhost' not in obj_classes: + obj_classes.append('ipasshhost') + return dn def post_callback(self, ldap, dn, entry_attrs, *keys, **options): @@ -720,6 +784,8 @@ class host_mod(LDAPUpdate): self.obj.suppress_netgroup_memberof(entry_attrs) + output_sshpubkey(ldap, dn, entry_attrs) + return dn api.register(host_mod) @@ -802,6 +868,8 @@ class host_find(LDAPSearch): if options.get('all', False): entry_attrs['managing'] = self.obj.get_managed_hosts(entry[0]) + output_sshpubkey(ldap, dn, entry_attrs) + api.register(host_find) @@ -831,6 +899,8 @@ class host_show(LDAPRetrieve): self.obj.suppress_netgroup_memberof(entry_attrs) + output_sshpubkey(ldap, dn, entry_attrs) + return dn def forward(self, *keys, **options): diff --git a/ipalib/plugins/user.py b/ipalib/plugins/user.py index 8c4cc49a0..ad9805bec 100644 --- a/ipalib/plugins/user.py +++ b/ipalib/plugins/user.py @@ -18,16 +18,18 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from time import gmtime, strftime +import copy +import string + from ipalib import api, errors -from ipalib import Flag, Int, Password, Str, Bool +from ipalib import Flag, Int, Password, Str, Bool, Bytes from ipalib.plugins.baseldap import * from ipalib.request import context -from time import gmtime, strftime -import copy from ipalib import _, ngettext from ipapython.ipautil import ipa_generate_password -import string import posixpath +from ipalib.util import validate_sshpubkey, output_sshpubkey __doc__ = _(""" Users @@ -154,12 +156,12 @@ class user(LDAPObject): 'uid', 'givenname', 'sn', 'homedirectory', 'loginshell', 'uidnumber', 'gidnumber', 'mail', 'ou', 'telephonenumber', 'title', 'memberof', 'nsaccountlock', - 'memberofindirect', + 'memberofindirect', 'sshpubkeyfp', ] search_display_attributes = [ 'uid', 'givenname', 'sn', 'homedirectory', 'loginshell', 'mail', 'telephonenumber', 'title', 'nsaccountlock', - 'uidnumber', 'gidnumber', + 'uidnumber', 'gidnumber', 'sshpubkeyfp', ] uuid_attribute = 'ipauniqueid' attribute_members = { @@ -310,6 +312,15 @@ class user(LDAPObject): label=_('Account disabled'), flags=['no_create', 'no_update', 'no_search'], ), + Bytes('ipasshpubkey*', validate_sshpubkey, + cli_name='sshpubkey', + label=_('Base-64 encoded SSH public key'), + flags=['no_search'], + ), + Str('sshpubkeyfp*', + label=_('SSH public key fingerprint'), + flags=['virtual_attribute', 'no_create', 'no_update', 'no_search'], + ), ) def _normalize_email(self, email, config=None): @@ -489,6 +500,9 @@ class user_add(LDAPCreate): pass self.obj.get_password_attributes(ldap, dn, entry_attrs) + + output_sshpubkey(ldap, dn, entry_attrs) + return dn api.register(user_add) @@ -522,6 +536,14 @@ class user_mod(LDAPUpdate): entry_attrs['userpassword'] = ipa_generate_password(user_pwdchars) # save the password so it can be displayed in post_callback setattr(context, 'randompassword', entry_attrs['userpassword']) + if 'ipasshpubkey' in entry_attrs: + if 'objectclass' in entry_attrs: + obj_classes = entry_attrs['objectclass'] + else: + (_dn, _entry_attrs) = ldap.get_entry(dn, ['objectclass']) + obj_classes = entry_attrs['objectclass'] = _entry_attrs['objectclass'] + if 'ipasshuser' not in obj_classes: + obj_classes.append('ipasshuser') return dn def post_callback(self, ldap, dn, entry_attrs, *keys, **options): @@ -534,6 +556,7 @@ class user_mod(LDAPUpdate): convert_nsaccountlock(entry_attrs) self.obj._convert_manager(entry_attrs, **options) self.obj.get_password_attributes(ldap, dn, entry_attrs) + output_sshpubkey(ldap, dn, entry_attrs) return dn api.register(user_mod) @@ -567,6 +590,7 @@ class user_find(LDAPSearch): self.obj._convert_manager(attrs, **options) self.obj.get_password_attributes(ldap, dn, attrs) convert_nsaccountlock(attrs) + output_sshpubkey(ldap, dn, attrs) msg_summary = ngettext( '%(count)d user matched', '%(count)d users matched', 0 @@ -584,6 +608,7 @@ class user_show(LDAPRetrieve): convert_nsaccountlock(entry_attrs) self.obj._convert_manager(entry_attrs, **options) self.obj.get_password_attributes(ldap, dn, entry_attrs) + output_sshpubkey(ldap, dn, entry_attrs) return dn api.register(user_show) diff --git a/ipalib/util.py b/ipalib/util.py index f3d7970db..365dd3399 100644 --- a/ipalib/util.py +++ b/ipalib/util.py @@ -32,6 +32,7 @@ from weakref import WeakKeyDictionary from ipalib import errors from ipalib.text import _ from ipapython import dnsclient +from ipapython.ipautil import decode_ssh_pubkey def json_serialize(obj): @@ -278,6 +279,37 @@ def validate_hostname(hostname, check_fqdn=True): raise ValueError(_('only letters, numbers, and - are allowed. ' \ '- must not be the last name character')) +def validate_sshpubkey(ugettext, pubkey): + try: + algo, data, fp = decode_ssh_pubkey(pubkey) + except ValueError: + return _('invalid SSH public key') + +def output_sshpubkey(ldap, dn, entry_attrs): + if 'ipasshpubkey' in entry_attrs: + pubkeys = entry_attrs.get('ipasshpubkey') + else: + entry = ldap.get_entry(dn, ['ipasshpubkey']) + pubkeys = entry[1].get('ipasshpubkey') + if pubkeys is None: + return + + fingerprints = [] + for pubkey in pubkeys: + try: + algo, data, fp = decode_ssh_pubkey(pubkey) + fp = u':'.join([fp[j:j+2] for j in range(0, len(fp), 2)]) + fingerprints.append(u'%s (%s)' % (fp, algo)) + except ValueError: + pass + if fingerprints: + entry_attrs['sshpubkeyfp'] = fingerprints + +def normalize_sshpubkeyfp(value): + value = value.split()[0] + value = unicode(c for c in value if c in '0123456789ABCDEFabcdef') + return value + class cachedproperty(object): """ A property-like attribute that caches the return value of a method call. diff --git a/ipapython/ipautil.py b/ipapython/ipautil.py index fc0010d6e..d9b0455e5 100644 --- a/ipapython/ipautil.py +++ b/ipapython/ipautil.py @@ -36,6 +36,7 @@ import shutil import urllib2 import socket import ldap +import struct from ipapython import ipavalidate from types import * @@ -58,6 +59,7 @@ except ImportError: self.cmd = cmd def __str__(self): return "Command '%s' returned non-zero exit status %d" % (self.cmd, self.returncode) +from ipapython.compat import sha1, md5 def get_domain_name(): try: @@ -1395,3 +1397,22 @@ def backup_config_and_replace_variables(fstore, filepath, replacevars=dict(), ap old_values = config_replace_variables(filepath, replacevars, appendvars) return old_values + +def decode_ssh_pubkey(data, fptype=md5): + try: + (algolen,) = struct.unpack('>I', data[:4]) + if algolen > 0 and algolen <= len(data) - 4: + return (data[4:algolen+4], data[algolen+4:], fptype(data).hexdigest().upper()) + except struct.error: + pass + raise ValueError('not a SSH public key') + +def make_sshfp(key): + algo, data, fp = decode_ssh_pubkey(key, fptype=sha1) + if algo == 'ssh-rsa': + algo = 1 + elif algo == 'ssh-dss': + algo = 2 + else: + return + return '%d 1 %s' % (algo, fp) diff --git a/ipaserver/install/krbinstance.py b/ipaserver/install/krbinstance.py index 6566d8a3a..75907174f 100644 --- a/ipaserver/install/krbinstance.py +++ b/ipaserver/install/krbinstance.py @@ -110,7 +110,7 @@ class KrbInstance(service.Service): # Create a host entry for this master host_dn = "fqdn=%s,cn=computers,cn=accounts,%s" % (self.fqdn, self.suffix) host_entry = ipaldap.Entry(host_dn) - host_entry.setValues('objectclass', ['top', 'ipaobject', 'nshost', 'ipahost', 'ipaservice', 'pkiuser', 'krbprincipalaux', 'krbprincipal', 'krbticketpolicyaux']) + host_entry.setValues('objectclass', ['top', 'ipaobject', 'nshost', 'ipahost', 'ipaservice', 'pkiuser', 'krbprincipalaux', 'krbprincipal', 'krbticketpolicyaux', 'ipasshhost']) host_entry.setValues('krbextradata', service_entry.getValues('krbextradata')) host_entry.setValue('krblastpwdchange', service_entry.getValue('krblastpwdchange')) if 'krbpasswordexpiration' in service_entry.toDict(): diff --git a/tests/test_xmlrpc/objectclasses.py b/tests/test_xmlrpc/objectclasses.py index cdcc6420c..346d52c02 100644 --- a/tests/test_xmlrpc/objectclasses.py +++ b/tests/test_xmlrpc/objectclasses.py @@ -31,6 +31,8 @@ user_base = [ u'krbprincipalaux', u'krbticketpolicyaux', u'ipaobject', + u'ipasshuser', + u'ipaSshGroupOfPubKeys', ] user = user_base + [u'mepOriginEntry'] @@ -44,6 +46,8 @@ group = [ ] host = [ + u'ipasshhost', + u'ipaSshGroupOfPubKeys', u'ieee802device', u'ipaobject', u'nshost', -- cgit