diff --git a/doc/topics/releases/neon.rst b/doc/topics/releases/neon.rst index ac1d679f7e26..5fd95a85f137 100644 --- a/doc/topics/releases/neon.rst +++ b/doc/topics/releases/neon.rst @@ -78,6 +78,14 @@ The slot syntax has been updated to support parsing dictionary responses and to Duration: 1.229 ms Changes: + +State Changes +============= + +- Added new :py:func:`ssh_auth.manage ` state to + ensure only the specified ssh keys are present for the specified user. + + Deprecations ============ diff --git a/salt/states/ssh_auth.py b/salt/states/ssh_auth.py index 2aff1dc1e9d4..d73330dff175 100644 --- a/salt/states/ssh_auth.py +++ b/salt/states/ssh_auth.py @@ -44,6 +44,20 @@ - ssh-dss AAAAB3NzaCL0sQ9fJ5bYTEyY== user@domain - option3="value3" ssh-dss AAAAB3NzaC1kcQ9J5bYTEyY== other@testdomain - AAAAB3NzaC1kcQ9fJFF435bYTEyY== newcomment + + sshkeys: + ssh_auth.manage: + - user: root + - enc: ssh-rsa + - options: + - option1="value1" + - option2="value2 flag2" + - comment: myuser + - ssh_keys: + - AAAAB3NzaC1kc3MAAACBAL0sQ9fJ5bYTEyY== + - ssh-dss AAAAB3NzaCL0sQ9fJ5bYTEyY== user@domain + - option3="value3" ssh-dss AAAAB3NzaC1kcQ9J5bYTEyY== other@testdomain + - AAAAB3NzaC1kcQ9fJFF435bYTEyY== newcomment ''' # Import python libs @@ -125,7 +139,7 @@ def _present_test(user, name, enc, comment, options, source, config, fingerprint elif check == 'exists': result = True comment = ('The authorized host key {0} is already present ' - 'for user {1}'.format(name, user)) + 'for user {1}'.format(name, user)) return result, comment @@ -251,14 +265,7 @@ def present( fingerprint_hash_type The public key fingerprint hash type that the public key fingerprint - was originally hashed with. This defaults to ``md5`` if not specified. - - .. versionadded:: 2016.11.7 - - .. note:: - - The default value of the ``fingerprint_hash_type`` will change to - ``sha256`` in Salt 2017.7.0. + was originally hashed with. This defaults to ``sha256`` if not specified. ''' ret = {'name': name, 'changes': {}, @@ -325,7 +332,7 @@ def present( saltenv=__env__, fingerprint_hash_type=fingerprint_hash_type) else: - # Split keyline to get key und comment + # Split keyline to get key and comment keyline = keyline.split(' ') key_type = keyline[0] key_value = keyline[1] @@ -423,15 +430,9 @@ def absent(name, fingerprint_hash_type The public key fingerprint hash type that the public key fingerprint - was originally hashed with. This defaults to ``md5`` if not specified. + was originally hashed with. This defaults to ``sha256`` if not specified. .. versionadded:: 2016.11.7 - - .. note:: - - The default value of the ``fingerprint_hash_type`` will change to - ``sha256`` in Salt 2017.7.0. - ''' ret = {'name': name, 'changes': {}, @@ -506,3 +507,99 @@ def absent(name, ret['changes'][name] = 'Removed' return ret + + +def manage( + name, + ssh_keys, + user, + enc='ssh-rsa', + comment='', + source='', + options=None, + config='.ssh/authorized_keys', + fingerprint_hash_type=None, + **kwargs): + ''' + .. versionadded:: Neon + + Ensures that only the specified ssh_keys are present for the specified user + + ssh_keys + The SSH key to manage + + user + The user who owns the SSH authorized keys file to modify + + enc + Defines what type of key is being used; can be ed25519, ecdsa, ssh-rsa + or ssh-dss + + comment + The comment to be placed with the SSH public key + + source + The source file for the key(s). Can contain any number of public keys, + in standard "authorized_keys" format. If this is set, comment and enc + will be ignored. + + .. note:: + The source file must contain keys in the format `` + ``. If you have generated a keypair using PuTTYgen, then you + will need to do the following to retrieve an OpenSSH-compatible public + key. + + 1. In PuTTYgen, click ``Load``, and select the *private* key file (not + the public key), and click ``Open``. + 2. Copy the public key from the box labeled ``Public key for pasting + into OpenSSH authorized_keys file``. + 3. Paste it into a new file. + + options + The options passed to the keys, pass a list object + + config + The location of the authorized keys file relative to the user's home + directory, defaults to ".ssh/authorized_keys". Token expansion %u and + %h for username and home path supported. + + 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. + ''' + ret = {'name': '', + 'changes': {}, + 'result': True, + 'comment': ''} + + all_potential_keys = [] + for ssh_key in ssh_keys: + # gather list potential ssh keys for removal comparison + # options, enc, and comments could be in the mix + all_potential_keys.extend(ssh_key.split(' ')) + existing_keys = __salt__['ssh.auth_keys'](user=user).keys() + remove_keys = set(existing_keys).difference(all_potential_keys) + for remove_key in remove_keys: + if __opts__['test']: + remove_comment = '{0} Key set for removal'.format(remove_key) + ret['comment'] = remove_comment + ret['result'] = None + else: + remove_comment = absent(remove_key, user)['comment'] + ret['changes'][remove_key] = remove_comment + + for ssh_key in ssh_keys: + run_return = present(ssh_key, user, enc, comment, source, + options, config, fingerprint_hash_type, **kwargs) + if run_return['changes']: + ret['changes'].update(run_return['changes']) + else: + ret['comment'] += '\n' + run_return['comment'] + ret['comment'] = ret['comment'].strip() + + if run_return['result'] is None: + ret['result'] = None + elif not run_return['result']: + ret['result'] = False + + return ret diff --git a/tests/unit/states/test_ssh_auth.py b/tests/unit/states/test_ssh_auth.py index 57a7cc99afb7..2d3bb084783d 100644 --- a/tests/unit/states/test_ssh_auth.py +++ b/tests/unit/states/test_ssh_auth.py @@ -102,3 +102,78 @@ def test_absent(self): ret.update({'comment': comt, 'result': True, 'changes': {name: 'Removed'}}) self.assertDictEqual(ssh_auth.absent(name, user, source), ret) + + def test_manage(self): + ''' + Test to verifies that the specified SSH key is absent. + ''' + user = 'root' + ret = {'name': '', + 'changes': {}, + 'result': None, + 'comment': ''} + + mock_rm = MagicMock(side_effect=['User authorized keys file not present', + 'Key removed']) + mock_up = MagicMock(side_effect=['update', 'updated']) + mock_set = MagicMock(side_effect=['replace', 'new']) + mock_keys = MagicMock(return_value={'somekey': { + "enc": "ssh-rsa", + "comment": "user@host", + "options": [], + "fingerprint": "b7"}}) + with patch.dict(ssh_auth.__salt__, {'ssh.rm_auth_key': mock_rm, + 'ssh.set_auth_key': mock_set, + 'ssh.check_key': mock_up, + 'ssh.auth_keys': mock_keys}): + with patch('salt.states.ssh_auth.present') as call_mocked_present: + mock_present = {'comment': '', + 'changes': {}, + 'result': None + } + call_mocked_present.return_value = mock_present + with patch.dict(ssh_auth.__opts__, {'test': True}): + # test: expected keys found. No chanages + self.assertDictEqual(ssh_auth.manage('sshid', ['somekey'], user), ret) + + comt = ('somekey Key set for removal') + ret.update({'comment': comt}) + # test: unexpected sshkey found. Should be removed. + self.assertDictEqual(ssh_auth.manage('sshid', [], user), ret) + + with patch('salt.states.ssh_auth.present') as call_mocked_present: + mock_present = {'comment': '', + 'changes': {}, + 'result': True + } + call_mocked_present.return_value = mock_present + with patch.dict(ssh_auth.__opts__, {'test': False}): + # expected keys found. No changes + ret = {'name': '', + 'changes': {}, + 'result': True, + 'comment': ''} + self.assertDictEqual(ssh_auth.manage('sshid', ['somekey'], user), ret) + + with patch('salt.states.ssh_auth.absent') as call_mocked_absent: + mock_absent = {'comment': 'Key removed'} + call_mocked_absent.return_value = mock_absent + ret.update({'comment': '', 'result': True, + 'changes': {'somekey': 'Key removed'}}) + # unexpected sshkey found. Was removed. + self.assertDictEqual(ssh_auth.manage('sshid', ['addkey'], user), ret) + + # add a key + with patch('salt.states.ssh_auth.present') as call_mocked_present: + mock_present = {'comment': 'The authorized host key newkey for user {} was added'.format(user), + 'changes': {'newkey': 'New'}, + 'result': True + } + call_mocked_present.return_value = mock_present + with patch.dict(ssh_auth.__opts__, {'test': False}): + # added key newkey + ret = {'name': '', + 'changes': {'newkey': 'New'}, + 'result': True, + 'comment': ''} + self.assertDictEqual(ssh_auth.manage('sshid', ['newkey', 'somekey'], user), ret)