Improvements to SSH known_hosts module and state.

This commit is contained in:
Alexei Pope Lane 2017-09-13 14:54:10 +10:00
parent 054f7c9551
commit ad80801d0f
6 changed files with 204 additions and 79 deletions

View File

@ -795,6 +795,22 @@ def set_auth_key(
return 'new'
def _get_matched_host_line_numbers(lines, enc):
'''
Helper function which parses ssh-keygen -F function output and yield line
number of known_hosts entries with encryption key type matching enc,
one by one.
'''
enc = enc if enc else "rsa"
for i, line in enumerate(lines):
if i % 2 == 0:
line_no = int(line.strip().split()[-1])
line_enc = lines[i + 1].strip().split()[-2]
if line_enc != enc:
continue
yield line_no
def _parse_openssh_output(lines, fingerprint_hash_type=None):
'''
Helper function which parses ssh-keygen -F and ssh-keyscan function output
@ -837,6 +853,33 @@ def get_known_host(user,
salt '*' ssh.get_known_host <user> <hostname>
'''
salt.utils.warn_until(
'Neon',
'get_known_host has been deprecated in favour of '
'get_known_host_entries.'
)
known_hosts = get_known_host_entries(user, hostname, config, port, fingerprint_hash_type)
return known_hosts[0] if known_hosts else None
@salt.utils.decorators.path.which('ssh-keygen')
def get_known_host_entries(user,
hostname,
config=None,
port=None,
fingerprint_hash_type=None):
'''
.. versionadded:: Oxygen
Return information about known host entries from the configfile, if any.
If there are no entries for a matching hostname, return None.
CLI Example:
.. code-block:: bash
salt '*' ssh.get_known_host_entries <user> <hostname>
'''
full = _get_known_hosts_file(config=config, user=user)
if isinstance(full, dict):
@ -847,11 +890,11 @@ def get_known_host(user,
lines = __salt__['cmd.run'](cmd,
ignore_retcode=True,
python_shell=False).splitlines()
known_hosts = list(
known_host_entries = list(
_parse_openssh_output(lines,
fingerprint_hash_type=fingerprint_hash_type)
)
return known_hosts[0] if known_hosts else None
return known_host_entries if known_host_entries else None
@salt.utils.decorators.path.which('ssh-keyscan')
@ -872,9 +915,8 @@ def recv_known_host(hostname,
or ssh-dss
port
optional parameter, denoting the port of the remote host, which will be
used in case, if the public key will be requested from it. By default
the port 22 is used.
Optional parameter, denoting the port of the remote host on which an
SSH daemon is running. By default the port 22 is used.
hash_known_hosts : True
Hash all hostnames and addresses in the known hosts file.
@ -888,8 +930,8 @@ def recv_known_host(hostname,
.. versionadded:: 2016.3.0
fingerprint_hash_type
The public key fingerprint hash type that the public key fingerprint
was originally hashed with. This defaults to ``sha256`` if not specified.
The fingerprint hash type that the public key fingerprints were
originally hashed with. This defaults to ``sha256`` if not specified.
.. versionadded:: 2016.11.4
.. versionchanged:: 2017.7.0: default changed from ``md5`` to ``sha256``
@ -900,6 +942,60 @@ def recv_known_host(hostname,
salt '*' ssh.recv_known_host <hostname> enc=<enc> port=<port>
'''
salt.utils.warn_until(
'Neon',
'recv_known_host has been deprecated in favour of '
'recv_known_host_entries.'
)
known_hosts = recv_known_host_entries(hostname, enc, port, hash_known_hosts, timeout, fingerprint_hash_type)
return known_hosts[0] if known_hosts else None
@salt.utils.decorators.path.which('ssh-keyscan')
def recv_known_host_entries(hostname,
enc=None,
port=None,
hash_known_hosts=True,
timeout=5,
fingerprint_hash_type=None):
'''
.. versionadded:: Oxygen
Retrieve information about host public keys from remote server
hostname
The name of the remote host (e.g. "github.com")
enc
Defines what type of key is being used, can be ed25519, ecdsa ssh-rsa
or ssh-dss
port
Optional parameter, denoting the port of the remote host on which an
SSH daemon is running. By default the port 22 is used.
hash_known_hosts : True
Hash all hostnames and addresses in the known hosts file.
timeout : int
Set the timeout for connection attempts. If ``timeout`` seconds have
elapsed since a connection was initiated to a host or since the last
time anything was read from that host, then the connection is closed
and the host in question considered unavailable. Default is 5 seconds.
fingerprint_hash_type
The fingerprint hash type that the public key fingerprints were
originally hashed with. This defaults to ``sha256`` if not specified.
.. versionadded:: 2016.11.4
.. versionchanged:: 2017.7.0: default changed from ``md5`` to ``sha256``
CLI Example:
.. code-block:: bash
salt '*' ssh.recv_known_host_entries <hostname> enc=<enc> port=<port>
'''
# The following list of OSes have an old version of openssh-clients
# and thus require the '-t' option for ssh-keyscan
need_dash_t = ('CentOS-5',)
@ -920,9 +1016,9 @@ def recv_known_host(hostname,
while not lines and attempts > 0:
attempts = attempts - 1
lines = __salt__['cmd.run'](cmd, python_shell=False).splitlines()
known_hosts = list(_parse_openssh_output(lines,
known_host_entries = list(_parse_openssh_output(lines,
fingerprint_hash_type=fingerprint_hash_type))
return known_hosts[0] if known_hosts else None
return known_host_entries if known_host_entries else None
def check_known_host(user=None, hostname=None, key=None, fingerprint=None,
@ -953,18 +1049,20 @@ def check_known_host(user=None, hostname=None, key=None, fingerprint=None,
else:
config = config or '.ssh/known_hosts'
known_host = get_known_host(user,
known_host_entries = get_known_host_entries(user,
hostname,
config=config,
port=port,
fingerprint_hash_type=fingerprint_hash_type)
known_keys = [h['key'] for h in known_host_entries] if known_host_entries else []
known_fingerprints = [h['fingerprint'] for h in known_host_entries] if known_host_entries else []
if not known_host or 'fingerprint' not in known_host:
if not known_host_entries:
return 'add'
if key:
return 'exists' if key == known_host['key'] else 'update'
return 'exists' if key in known_keys else 'update'
elif fingerprint:
return ('exists' if fingerprint == known_host['fingerprint']
return ('exists' if fingerprint in known_fingerprints
else 'update')
else:
return 'exists'
@ -1084,70 +1182,99 @@ def set_known_host(user=None,
update_required = False
check_required = False
stored_host = get_known_host(user,
stored_host_entries = get_known_host_entries(user,
hostname,
config=config,
port=port,
fingerprint_hash_type=fingerprint_hash_type)
stored_keys = [h['key'] for h in stored_host_entries] if stored_host_entries else []
stored_fingerprints = [h['fingerprint'] for h in stored_host_entries] if stored_host_entries else []
if not stored_host:
if not stored_host_entries:
update_required = True
elif fingerprint and fingerprint != stored_host['fingerprint']:
elif fingerprint and fingerprint not in stored_fingerprints:
update_required = True
elif key and key != stored_host['key']:
elif key and key not in stored_keys:
update_required = True
elif key != stored_host['key']:
elif key is None and fingerprint is None:
check_required = True
if not update_required and not check_required:
return {'status': 'exists', 'key': stored_host['key']}
return {'status': 'exists', 'keys': stored_keys}
if not key:
remote_host = recv_known_host(hostname,
remote_host_entries = recv_known_host_entries(hostname,
enc=enc,
port=port,
hash_known_hosts=hash_known_hosts,
timeout=timeout,
fingerprint_hash_type=fingerprint_hash_type)
if not remote_host:
known_keys = [h['key'] for h in remote_host_entries] if remote_host_entries else []
known_fingerprints = [h['fingerprint'] for h in remote_host_entries] if remote_host_entries else []
if not remote_host_entries:
return {'status': 'error',
'error': 'Unable to receive remote host key'}
'error': 'Unable to receive remote host keys'}
if fingerprint and fingerprint != remote_host['fingerprint']:
if fingerprint and fingerprint not in known_fingerprints:
return {'status': 'error',
'error': ('Remote host public key found but its fingerprint '
'does not match one you have provided')}
'error': ('Remote host public keys found but none of their'
'fingerprints match the one you have provided')}
if check_required:
if remote_host['key'] == stored_host['key']:
return {'status': 'exists', 'key': stored_host['key']}
for key in known_keys:
if key in stored_keys:
return {'status': 'exists', 'keys': stored_keys}
full = _get_known_hosts_file(config=config, user=user)
if isinstance(full, dict):
return full
# Get information about the known_hosts file before rm_known_host()
# because it will create a new file with mode 0600
orig_known_hosts_st = None
try:
orig_known_hosts_st = os.stat(full)
except OSError as exc:
if exc.args[1] == 'No such file or directory':
log.debug('{0} doesnt exist. Nothing to preserve.'.format(full))
if os.path.isfile(full):
origmode = os.stat(full).st_mode
# remove everything we had in the config so far
rm_known_host(user, hostname, config=config)
# remove existing known_host entry with matching hostname and encryption key type
# use ssh-keygen -F to find the specific line(s) for this host + enc combo
ssh_hostname = _hostname_and_port_to_ssh_hostname(hostname, port)
cmd = ['ssh-keygen', '-F', ssh_hostname, '-f', full]
lines = __salt__['cmd.run'](cmd,
ignore_retcode=True,
python_shell=False).splitlines()
remove_lines = list(
_get_matched_host_line_numbers(lines, enc)
)
if remove_lines:
try:
with salt.utils.files.fopen(full, 'r+') as ofile:
known_hosts_lines = list(ofile)
# Delete from last line to first to avoid invalidating earlier indexes
for line_no in sorted(remove_lines, reverse=True):
del known_hosts_lines[line_no - 1]
# Write out changed known_hosts file
ofile.seek(0)
ofile.truncate()
for line in known_hosts_lines:
ofile.write(line)
except (IOError, OSError) as exception:
raise CommandExecutionError(
"Couldn't remove old entry(ies) from known hosts file: '{0}'".format(exception)
)
else:
origmode = None
# set up new value
if key:
remote_host = {'hostname': hostname, 'enc': enc, 'key': key}
remote_host_entries = [{'hostname': hostname, 'enc': enc, 'key': key}]
if hash_known_hosts or port in [DEFAULT_SSH_PORT, None] or ':' in remote_host['hostname']:
line = '{hostname} {enc} {key}\n'.format(**remote_host)
lines = []
for entry in remote_host_entries:
if hash_known_hosts or port in [DEFAULT_SSH_PORT, None] or ':' in entry['hostname']:
line = '{hostname} {enc} {key}\n'.format(**entry)
else:
remote_host['port'] = port
line = '[{hostname}]:{port} {enc} {key}\n'.format(**remote_host)
entry['port'] = port
line = '[{hostname}]:{port} {enc} {key}\n'.format(**entry)
lines.append(line)
# ensure ~/.ssh exists
ssh_dir = os.path.dirname(full)
@ -1173,27 +1300,25 @@ def set_known_host(user=None,
# write line to known_hosts file
try:
with salt.utils.files.fopen(full, 'a') as ofile:
for line in lines:
ofile.write(line)
except (IOError, OSError) as exception:
raise CommandExecutionError(
"Couldn't append to known hosts file: '{0}'".format(exception)
)
if os.geteuid() == 0:
if user:
if os.geteuid() == 0 and user:
os.chown(full, uinfo['uid'], uinfo['gid'])
elif orig_known_hosts_st:
os.chown(full, orig_known_hosts_st.st_uid, orig_known_hosts_st.st_gid)
if orig_known_hosts_st:
os.chmod(full, orig_known_hosts_st.st_mode)
if origmode:
os.chmod(full, origmode)
else:
os.chmod(full, 0o600)
if key and hash_known_hosts:
cmd_result = __salt__['ssh.hash_known_hosts'](user=user, config=full)
return {'status': 'updated', 'old': stored_host, 'new': remote_host}
rval = {'status': 'updated', 'old': stored_host_entries, 'new': remote_host_entries}
return rval
def user_keys(user=None, pubfile=None, prvfile=None):

View File

@ -178,13 +178,13 @@ def present(
return dict(ret, result=False, comment=result['error'])
else: # 'updated'
if key:
new_key = result['new']['key']
new_key = result['new'][0]['key']
return dict(ret,
changes={'old': result['old'], 'new': result['new']},
comment='{0}\'s key saved to {1} (key: {2})'.format(
name, config, new_key))
else:
fingerprint = result['new']['fingerprint']
fingerprint = result['new'][0]['fingerprint']
return dict(ret,
changes={'old': result['old'], 'new': result['new']},
comment='{0}\'s key saved to {1} (fingerprint: {2})'.format(
@ -225,7 +225,7 @@ def absent(name, user=None, config=None):
ret['result'] = False
return dict(ret, comment=comment)
known_host = __salt__['ssh.get_known_host'](user=user, hostname=name, config=config)
known_host = __salt__['ssh.get_known_host_entries'](user=user, hostname=name, config=config)
if not known_host:
return dict(ret, comment='Host is already absent')

View File

@ -104,7 +104,7 @@ class SSHModuleTest(ModuleCase):
# user will get an indicator of what went wrong.
self.assertEqual(len(list(ret.items())), 0) # Zero keys found
def test_get_known_host(self):
def test_get_known_host_entries(self):
'''
Check that known host information is returned from ~/.ssh/config
'''
@ -113,7 +113,7 @@ class SSHModuleTest(ModuleCase):
KNOWN_HOSTS)
arg = ['root', 'github.com']
kwargs = {'config': KNOWN_HOSTS}
ret = self.run_function('ssh.get_known_host', arg, **kwargs)
ret = self.run_function('ssh.get_known_host_entries', arg, **kwargs)[0]
try:
self.assertEqual(ret['enc'], 'ssh-rsa')
self.assertEqual(ret['key'], self.key)
@ -125,16 +125,16 @@ class SSHModuleTest(ModuleCase):
)
)
def test_recv_known_host(self):
def test_recv_known_host_entries(self):
'''
Check that known host information is returned from remote host
'''
ret = self.run_function('ssh.recv_known_host', ['github.com'])
ret = self.run_function('ssh.recv_known_host_entries', ['github.com'])
try:
self.assertNotEqual(ret, None)
self.assertEqual(ret['enc'], 'ssh-rsa')
self.assertEqual(ret['key'], self.key)
self.assertEqual(ret['fingerprint'], GITHUB_FINGERPRINT)
self.assertEqual(ret[0]['enc'], 'ssh-rsa')
self.assertEqual(ret[0]['key'], self.key)
self.assertEqual(ret[0]['fingerprint'], GITHUB_FINGERPRINT)
except AssertionError as exc:
raise AssertionError(
'AssertionError: {0}. Function returned: {1}'.format(
@ -215,7 +215,7 @@ class SSHModuleTest(ModuleCase):
try:
self.assertEqual(ret['status'], 'updated')
self.assertEqual(ret['old'], None)
self.assertEqual(ret['new']['fingerprint'], GITHUB_FINGERPRINT)
self.assertEqual(ret['new'][0]['fingerprint'], GITHUB_FINGERPRINT)
except AssertionError as exc:
raise AssertionError(
'AssertionError: {0}. Function returned: {1}'.format(
@ -223,8 +223,8 @@ class SSHModuleTest(ModuleCase):
)
)
# check that item does exist
ret = self.run_function('ssh.get_known_host', ['root', 'github.com'],
config=KNOWN_HOSTS)
ret = self.run_function('ssh.get_known_host_entries', ['root', 'github.com'],
config=KNOWN_HOSTS)[0]
try:
self.assertEqual(ret['fingerprint'], GITHUB_FINGERPRINT)
except AssertionError as exc:

View File

@ -66,7 +66,7 @@ class SSHKnownHostsStateTest(ModuleCase, SaltReturnAssertsMixin):
raise err
self.assertSaltStateChangesEqual(
ret, GITHUB_FINGERPRINT, keys=('new', 'fingerprint')
ret, GITHUB_FINGERPRINT, keys=('new', 0, 'fingerprint')
)
# save twice, no changes
@ -81,7 +81,7 @@ class SSHKnownHostsStateTest(ModuleCase, SaltReturnAssertsMixin):
**dict(kwargs, name=GITHUB_IP))
try:
self.assertSaltStateChangesEqual(
ret, GITHUB_FINGERPRINT, keys=('new', 'fingerprint')
ret, GITHUB_FINGERPRINT, keys=('new', 0, 'fingerprint')
)
except AssertionError as err:
try:
@ -94,8 +94,8 @@ class SSHKnownHostsStateTest(ModuleCase, SaltReturnAssertsMixin):
# record for every host must be available
ret = self.run_function(
'ssh.get_known_host', ['root', 'github.com'], config=KNOWN_HOSTS
)
'ssh.get_known_host_entries', ['root', 'github.com'], config=KNOWN_HOSTS
)[0]
try:
self.assertNotIn(ret, ('', None))
except AssertionError:
@ -103,8 +103,8 @@ class SSHKnownHostsStateTest(ModuleCase, SaltReturnAssertsMixin):
'Salt return \'{0}\' is in (\'\', None).'.format(ret)
)
ret = self.run_function(
'ssh.get_known_host', ['root', GITHUB_IP], config=KNOWN_HOSTS
)
'ssh.get_known_host_entries', ['root', GITHUB_IP], config=KNOWN_HOSTS
)[0]
try:
self.assertNotIn(ret, ('', None, {}))
except AssertionError:
@ -144,7 +144,7 @@ class SSHKnownHostsStateTest(ModuleCase, SaltReturnAssertsMixin):
# remove once, the key is gone
ret = self.run_state('ssh_known_hosts.absent', **kwargs)
self.assertSaltStateChangesEqual(
ret, GITHUB_FINGERPRINT, keys=('old', 'fingerprint')
ret, GITHUB_FINGERPRINT, keys=('old', 0, 'fingerprint')
)
# remove twice, nothing has changed

View File

@ -572,7 +572,7 @@ class ModuleCase(TestCase, SaltClientTestCaseMixin):
behavior of the raw function call
'''
know_to_return_none = (
'file.chown', 'file.chgrp', 'ssh.recv_known_host'
'file.chown', 'file.chgrp', 'ssh.recv_known_host_entries'
)
if 'f_arg' in kwargs:
kwargs['arg'] = kwargs.pop('f_arg')

View File

@ -96,7 +96,7 @@ class SshKnownHostsTestCase(TestCase, LoaderModuleMockMixin):
self.assertDictEqual(ssh_known_hosts.present(name, user), ret)
result = {'status': 'updated', 'error': '',
'new': {'fingerprint': fingerprint, 'key': key},
'new': [{'fingerprint': fingerprint, 'key': key}],
'old': ''}
mock = MagicMock(return_value=result)
with patch.dict(ssh_known_hosts.__salt__,
@ -104,8 +104,8 @@ class SshKnownHostsTestCase(TestCase, LoaderModuleMockMixin):
comt = ("{0}'s key saved to .ssh/known_hosts (key: {1})"
.format(name, key))
ret.update({'comment': comt, 'result': True,
'changes': {'new': {'fingerprint': fingerprint,
'key': key}, 'old': ''}})
'changes': {'new': [{'fingerprint': fingerprint,
'key': key}], 'old': ''}})
self.assertDictEqual(ssh_known_hosts.present(name, user,
key=key), ret)
@ -136,14 +136,14 @@ class SshKnownHostsTestCase(TestCase, LoaderModuleMockMixin):
mock = MagicMock(return_value=False)
with patch.dict(ssh_known_hosts.__salt__,
{'ssh.get_known_host': mock}):
{'ssh.get_known_host_entries': mock}):
comt = ('Host is already absent')
ret.update({'comment': comt, 'result': True})
self.assertDictEqual(ssh_known_hosts.absent(name, user), ret)
mock = MagicMock(return_value=True)
with patch.dict(ssh_known_hosts.__salt__,
{'ssh.get_known_host': mock}):
{'ssh.get_known_host_entries': mock}):
with patch.dict(ssh_known_hosts.__opts__, {'test': True}):
comt = ('Key for github.com is set to be'
' removed from .ssh/known_hosts')