Merge pull request #1315 from imankulov/ssh_known_hosts

Support for ssh_known_hosts in modules and states
This commit is contained in:
Thomas S Hatch 2012-05-22 09:32:20 -07:00
commit 99ea439ab6
7 changed files with 517 additions and 2 deletions

View File

@ -1,9 +1,10 @@
''' '''
Manage client ssh components Manage client ssh components
''' '''
import os import os
import re import re
import binascii
import hashlib
def _refine_enc(enc): def _refine_enc(enc):
@ -119,16 +120,38 @@ def _validate_keys(key_file):
enc = comps[0] enc = comps[0]
key = comps[1] key = comps[1]
comment = ' '.join(comps[2:]) comment = ' '.join(comps[2:])
fingerprint = _fingerprint(key)
if fingerprint is None:
continue
ret[key] = {'enc': enc, ret[key] = {'enc': enc,
'comment': comment, 'comment': comment,
'options': options} 'options': options,
'fingerprint': fingerprint}
except IOError: except IOError:
return {} return {}
return ret return ret
def _fingerprint(public_key):
"""
Return a public key fingerprint based on its base64-encoded representation
The fingerprint string is formatted according to RFC 4716 (ch.4), that is,
in the form "xx:xx:...:xx"
If the key is invalid (incorrect base64 string), return None
"""
try:
raw_key = public_key.decode('base64')
except binascii.Error:
return None
ret = hashlib.md5(raw_key).hexdigest()
chunks = [ret[i:i+2] for i in range(0, len(ret), 2)]
return ':'.join(chunks)
def host_keys(keydir=None): def host_keys(keydir=None):
''' '''
Return the minion's host keys Return the minion's host keys
@ -368,3 +391,167 @@ def set_auth_key(
else: else:
open(fconfig, 'a+').write('{0}'.format(auth_line)) open(fconfig, 'a+').write('{0}'.format(auth_line))
return 'new' return 'new'
def _parse_openssh_output(lines):
'''
Helper function which parses ssh-keygen -F and ssh-keyscan function output
and yield dict with keys information, one by one.
'''
for line in lines:
if line.startswith('#'):
continue
try:
hostname, enc, key = line.split()
except ValueError: # incorrect format
continue
fingerprint = _fingerprint(key)
if not fingerprint:
continue
yield {'hostname': hostname, 'key': key, 'enc': enc,
'fingerprint': fingerprint}
def get_known_host(user, hostname, config='.ssh/known_hosts'):
'''
Return information about known host from the configfile, if any.
If there is no such key, return None.
CLI Example::
salt '*' ssh.get_known_host <user> <hostname>
'''
uinfo = __salt__['user.info'](user)
full = os.path.join(uinfo['home'], config)
if not os.path.isfile(full):
return None
cmd = 'ssh-keygen -F "{0}" -f "{1}"'.format(hostname, full)
lines = __salt__['cmd.run'](cmd).splitlines()
known_hosts = list(_parse_openssh_output(lines))
return known_hosts[0] if known_hosts else None
def recv_known_host(user, hostname, enc=None, port=None, hash_hostname=False):
'''
Retreive information about host public key from remote server
CLI Example::
salt '*' ssh.recv_known_host <user> <hostname> enc=<enc> port=<port>
'''
chunks = ['ssh-keyscan', ]
if port:
chunks += ['-p', str(port)]
if enc:
chunks += ['-t', str(enc)]
if hash_hostname:
chunks.append('-H')
chunks.append(str(hostname))
cmd = ' '.join(chunks)
lines = __salt__['cmd.run'](cmd).splitlines()
known_hosts = list(_parse_openssh_output(lines))
return known_hosts[0] if known_hosts else None
def check_known_host(user, hostname, key=None, fingerprint=None,
config='.ssh/known_hosts'):
'''
Check the record in known_hosts file, either by its value or by fingerprint
(it's enough to set up either key or fingerprint, you don't need to set up
both).
If provided key or fingerprint doesn't match with stored value, return
"update", if no value is found for a given host, return "add", otherwise
return "exists".
If neither key, nor fingerprint is defined, then additional validation is
not performed.
CLI Example::
salt '*' ssh.check_known_host <user> <hostname> key='AAAA...FAaQ=='
'''
known_host = get_known_host(user, hostname, config=config)
if not known_host:
return 'add'
if key:
return 'exists' if key == known_host['key'] else 'update'
elif fingerprint:
return 'exists' if fingerprint == known_host['fingerprint'] else 'update'
else:
return 'exists'
def rm_known_host(user, hostname, config='.ssh/known_hosts'):
'''
Remove all keys belonging to hostname from a known_hosts file.
CLI Example::
salt '*' ssh.rm_known_host <user> <hostname>
'''
uinfo = __salt__['user.info'](user)
full = os.path.join(uinfo['home'], config)
if not os.path.isfile(full):
return {'status': 'error',
'error': 'Known hosts file {0} does not exist'.format(full)}
cmd = 'ssh-keygen -R "{0}" -f "{1}"'.format(hostname, full)
cmd_result = __salt__['cmd.run'](cmd).strip()
return {'status': 'removed', 'comment': cmd_result}
def set_known_host(user, hostname,
fingerprint=None,
port=None,
enc=None,
hash_hostname=True,
config='.ssh/known_hosts'):
'''
Download SSH public key from remote host "hostname", optionally validate
its fingerprint against "fingerprint" variable and save the record in the
known_hosts file.
If such a record does already exists in there, do nothing.
CLI Example::
salt '*' ssh.set_known_host <user> fingerprint='xx:xx:..:xx' enc='ssh-rsa'\
config='.ssh/known_hosts'
'''
update_required = False
stored_host = get_known_host(user, hostname, config)
if not stored_host:
update_required = True
elif fingerprint and fingerprint != stored_host['fingerprint']:
update_required = True
if not update_required:
return {'status': 'exists', 'key': stored_host}
remote_host = recv_known_host(user, hostname, enc=enc, port=port,
hash_hostname=True)
if not remote_host:
return {'status': 'error',
'error': 'Unable to receive remote host key'}
if fingerprint and fingerprint != remote_host['fingerprint']:
return {'status': 'error',
'error': ('Remote host public key found but its fingerprint '
'does not match one you have provided')}
# remove everything we had in the config so far
rm_known_host(user, hostname, config=config)
# set up new value
uinfo = __salt__['user.info'](user)
full = os.path.join(uinfo['home'], config)
line = '{hostname} {enc} {key}\n'.format(**remote_host)
with open(full, 'w') as fd:
fd.write(line)
return {'status': 'updated', 'old': stored_host, 'new': remote_host}
status = check_known_host(user, hostname, fingerprint=fingerprint,
config=config)
if status == 'exists':
return None

View File

@ -0,0 +1,104 @@
'''
SSH known hosts management
==========================
Manage the information stored in the known_hosts files
.. code-block:: yaml
github.com:
ssh_known_hosts:
- present
- user: root
- fingerprint: 16:27:ac:a5:76:28:2d:36:63:1b:56:4d:eb:df:a6:48
example.com:
ssh_known_hosts:
- absent
- user: root
'''
def present(
name,
user,
fingerprint=None,
port=None,
enc=None,
config='.ssh/known_hosts'):
'''
Verifies that the specified host is known by the specified user
name
The name of the remote host (i.e. "github.com")
user
The user who owns the ssh authorized keys file to modify
enc
Defines what type of key is being used, can be ssh-rsa or ssh-dss
fingerprint
The fingerprint of the key which must be presented in the known_hosts
file
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.
config
The location of the authorized keys file relative to the user's home
directory, defaults to ".ssh/known_hosts"
'''
ret = {'name': name,
'changes': {},
'result': True,
'comment': ''}
result = __salt__['ssh.set_known_host'](user, name,
fingerprint=fingerprint,
port=port,
enc=enc,
config=config)
if result['status'] == 'exists':
return {'name': name,
'result': None,
'comment': '{0} already exists in {1}'.format(name, config)}
elif result['status'] == 'error':
return {'name': name,
'result': False,
'comment': result['error']}
else: # 'updated'
return {'name': name,
'result': True,
'changes': {'old': result['old'], 'new': result['new']},
'comment': '{0}\'s key saved to {1} (fingerprint: {2})'.format(
name, config, result['new']['fingerprint'])}
def absent(name, user, config='.ssh/known_hosts'):
'''
Verifies that the specified host is not known by the given user
name
The host name
user
The user who owns the ssh authorized keys file to modify
config
The location of the authorized keys file relative to the user's home
directory, defaults to ".ssh/known_hosts"
'''
ret = {'name': name,
'changes': {},
'result': True,
'comment': ''}
known_host = __salt__['ssh.get_known_host'](user, name, config=config)
if not known_host:
return dict(ret, result=None, comment='Host is already absent')
rm_result = __salt__['ssh.rm_known_host'](user, name, config=config)
if rm_result['status'] == 'error':
return dict(ret, result=False, comment=rm_result['error'])
else:
return dict(ret, result=True, comment=rm_result['comment'])

View File

@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ== github.com

View File

@ -0,0 +1 @@
|1|muzcBqgq7+ByUY7aLICytOff8UI=|rZ1JBNlIOqRnwwsJl9yP+xMxgf8= ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==

View File

@ -0,0 +1 @@
AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==

View File

@ -0,0 +1,148 @@
'''
Test the ssh module
'''
# Import python libs
import os
import shutil
import sys
# Import Salt libs
from saltunittest import TestLoader, TextTestRunner
import integration
from integration import TestDaemon
AUTHORIZED_KEYS = os.path.join(integration.TMP, 'authorized_keys')
KNOWN_HOSTS = os.path.join(integration.TMP, 'known_hosts')
GITHUB_FINGERPRINT = '16:27:ac:a5:76:28:2d:36:63:1b:56:4d:eb:df:a6:48'
class SSHModuleTest(integration.ModuleCase):
'''
Test the ssh module
'''
def setUp(self):
super(SSHModuleTest, self).setUp()
with open(os.path.join(integration.FILES, 'ssh', 'raw')) as fd:
self.key = fd.read().strip()
def tearDown(self):
if os.path.isfile(AUTHORIZED_KEYS):
os.remove(AUTHORIZED_KEYS)
if os.path.isfile(KNOWN_HOSTS):
os.remove(KNOWN_HOSTS)
super(SSHModuleTest, self).tearDown()
def test_auth_keys(self):
shutil.copyfile(
os.path.join(integration.FILES, 'ssh', 'authorized_keys'),
AUTHORIZED_KEYS)
ret = self.run_function('ssh.auth_keys', ['root', AUTHORIZED_KEYS])
self.assertEqual(len(ret.items()), 1) # exactply one key is found
key_data = ret.items()[0][1]
self.assertEqual(key_data['comment'], 'github.com')
self.assertEqual(key_data['enc'], 'ssh-rsa')
self.assertEqual(key_data['options'], [])
self.assertEqual(key_data['fingerprint'], GITHUB_FINGERPRINT)
def test_get_known_host(self):
"""
Check that known host information is returned from ~/.ssh/config
"""
shutil.copyfile(
os.path.join(integration.FILES, 'ssh', 'known_hosts'),
KNOWN_HOSTS)
arg = ['root', 'github.com']
kwargs = {'config': KNOWN_HOSTS}
ret = self.run_function('ssh.get_known_host', arg, **kwargs)
self.assertEqual(ret['enc'], 'ssh-rsa')
self.assertEqual(ret['key'], self.key)
self.assertEqual(ret['fingerprint'], GITHUB_FINGERPRINT)
def test_recv_known_host(self):
"""
Check that known host information is returned from remote host
"""
ret = self.run_function('ssh.recv_known_host', ['root', 'github.com'])
self.assertEqual(ret['enc'], 'ssh-rsa')
self.assertEqual(ret['key'], self.key)
self.assertEqual(ret['fingerprint'], GITHUB_FINGERPRINT)
def test_check_known_host_add(self):
"""
Check known hosts by its fingerprint. File needs to be updated
"""
arg = ['root', 'github.com']
kwargs = {'fingerprint': GITHUB_FINGERPRINT, 'config': KNOWN_HOSTS}
ret = self.run_function('ssh.check_known_host', arg, **kwargs)
self.assertEqual(ret, 'add')
def test_check_known_host_update(self):
shutil.copyfile(
os.path.join(integration.FILES, 'ssh', 'known_hosts'),
KNOWN_HOSTS)
arg = ['root', 'github.com']
kwargs = {'config': KNOWN_HOSTS}
# wrong fingerprint
ret = self.run_function('ssh.check_known_host', arg,
**dict(kwargs, fingerprint='aa:bb:cc:dd'))
self.assertEqual(ret, 'update')
# wrong keyfile
ret = self.run_function('ssh.check_known_host', arg,
**dict(kwargs, key='YQ=='))
self.assertEqual(ret, 'update')
def test_check_known_host_exists(self):
shutil.copyfile(
os.path.join(integration.FILES, 'ssh', 'known_hosts'),
KNOWN_HOSTS)
arg = ['root', 'github.com']
kwargs = {'config': KNOWN_HOSTS}
# wrong fingerprint
ret = self.run_function('ssh.check_known_host', arg,
**dict(kwargs, fingerprint=GITHUB_FINGERPRINT))
self.assertEqual(ret, 'exists')
# wrong keyfile
ret = self.run_function('ssh.check_known_host', arg,
**dict(kwargs, key=self.key))
self.assertEqual(ret, 'exists')
def test_rm_known_host(self):
shutil.copyfile(
os.path.join(integration.FILES, 'ssh', 'known_hosts'),
KNOWN_HOSTS)
arg = ['root', 'github.com']
kwargs = {'config': KNOWN_HOSTS, 'key': self.key}
# before removal
ret = self.run_function('ssh.check_known_host', arg, **kwargs)
self.assertEqual(ret, 'exists')
# remove
self.run_function('ssh.rm_known_host', arg, config=KNOWN_HOSTS)
# after removal
ret = self.run_function('ssh.check_known_host', arg, **kwargs)
self.assertEqual(ret, 'add')
def test_set_known_host(self):
# add item
ret = self.run_function('ssh.set_known_host', ['root', 'github.com'],
config=KNOWN_HOSTS)
self.assertEqual(ret['status'], 'updated')
self.assertEqual(ret['old'], None)
self.assertEqual(ret['new']['fingerprint'], GITHUB_FINGERPRINT)
# check that item does exist
ret = self.run_function('ssh.get_known_host', ['root', 'github.com'],
config=KNOWN_HOSTS)
self.assertEqual(ret['fingerprint'], GITHUB_FINGERPRINT)
# add the same item once again
ret = self.run_function('ssh.set_known_host', ['root', 'github.com'],
config=KNOWN_HOSTS)
self.assertEqual(ret['status'], 'exists')
if __name__ == "__main__":
loader = TestLoader()
tests = loader.loadTestsFromTestCase(SSHModuleTest)
print('Setting up Salt daemons to execute tests')
with TestDaemon():
runner = TextTestRunner(verbosity=1).run(tests)
sys.exit(runner.wasSuccessful())

View File

@ -0,0 +1,73 @@
'''
Test the ssh_known_hosts state
'''
import os
import shutil
import integration
KNOWN_HOSTS = os.path.join(integration.TMP, 'known_hosts')
GITHUB_FINGERPRINT = '16:27:ac:a5:76:28:2d:36:63:1b:56:4d:eb:df:a6:48'
class SSHKnownHostsStateTest(integration.ModuleCase):
'''
Validate the ssh state
'''
def tearDown(self):
if os.path.isfile(KNOWN_HOSTS):
os.remove(KNOWN_HOSTS)
super(SSHKnownHostsStateTest, self).tearDown()
def test_present(self):
'''
ssh_known_hosts.present
'''
# save once
_ret = self.run_state('ssh_known_hosts.present',
name='github.com',
user='root',
fingerprint=GITHUB_FINGERPRINT,
config=KNOWN_HOSTS)
ret = _ret.values()[0]
self.assertTrue(ret['result'], ret)
# save twice
_ret = self.run_state('ssh_known_hosts.present',
name='github.com',
user='root',
fingerprint=GITHUB_FINGERPRINT,
config=KNOWN_HOSTS)
ret = _ret.values()[0]
self.assertEqual(ret['result'], None, ret)
def test_present_fail(self):
# save something wrong
_ret = self.run_state('ssh_known_hosts.present',
name='github.com',
user='root',
fingerprint='aa:bb:cc:dd',
config=KNOWN_HOSTS)
ret = _ret.values()[0]
self.assertFalse(ret['result'], ret)
def test_absent(self):
'''
ssh_known_hosts.absent
'''
shutil.copyfile(
os.path.join(integration.FILES, 'ssh', 'known_hosts'),
KNOWN_HOSTS)
# remove once
_ret = self.run_state('ssh_known_hosts.absent',
name='github.com',
user='root',
config=KNOWN_HOSTS)
ret = _ret.values()[0]
self.assertTrue(ret['result'], ret)
# remove twice
_ret = self.run_state('ssh_known_hosts.absent',
name='github.com',
user='root',
config=KNOWN_HOSTS)
ret = _ret.values()[0]
self.assertEqual(ret['result'], None, ret)