diff --git a/lib/ansible/modules/network/iosxr/iosxr_user.py b/lib/ansible/modules/network/iosxr/iosxr_user.py index dc2ad2b7aa..fd9cbca6b0 100644 --- a/lib/ansible/modules/network/iosxr/iosxr_user.py +++ b/lib/ansible/modules/network/iosxr/iosxr_user.py @@ -16,7 +16,9 @@ DOCUMENTATION = """ --- module: iosxr_user version_added: "2.4" -author: "Trishna Guha (@trishnaguha)" +author: + - "Trishna Guha (@trishnaguha)" + - "Sebastiaan van Doesselaar (@sebasdoes)" short_description: Manage the aggregate of local users on Cisco IOS XR device description: - This module provides declarative management of the local usernames @@ -60,6 +62,14 @@ options: device running configuration. The argument accepts a string value defining the group name. This argument does not check if the group has been configured on the device, alias C(role). + groups: + version_added: "2.5" + description: + - Configures the groups for the username in the device running + configuration. The argument accepts a list of group names. + This argument does not check if the group has been configured + on the device. It is similar to the aggregrate command for + usernames, but lets you configure multiple groups for the user(s). purge: description: - Instructs the module to consider the @@ -77,6 +87,29 @@ options: in the device active configuration default: present choices: ['present', 'absent'] + public_key: + version_added: "2.5" + description: + - Configures the contents of the public keyfile to upload to the IOS-XR node. + This enables users to login using the accompanying private key. IOS-XR + only accepts base64 decoded files, so this will be decoded and uploaded + to the node. Do note that this requires an OpenSSL public key file, + PuTTy generated files will not work! Mutually exclusive with + public_key_contents. If used with multiple users in aggregates, then the + same key file is used for all users. + public_key_contents: + version_added: "2.5" + description: + - Configures the contents of the public keyfile to upload to the IOS-XR node. + This enables users to login using the accompanying private key. IOS-XR + only accepts base64 decoded files, so this will be decoded and uploaded + to the node. Do note that this requires an OpenSSL public key file, + PuTTy generated files will not work! Mutually exclusive with + public_key.If used with multiple users in aggregates, then the + same key file is used for all users. +requirements: + - base64 when using I(public_key_contents) or I(public_key) + - paramiko when using I(public_key_contents) or I(public_key) """ EXAMPLES = """ @@ -95,12 +128,26 @@ EXAMPLES = """ - name: netend group: sysadmin state: present +- name: set multiple users to multiple groups + iosxr_user: + aggregate: + - name: netop + - name: netend + groups: + - sysadmin + - root-system + state: present - name: Change Password for User netop iosxr_user: name: netop configured_password: "{{ new_password }}" update_password: always state: present +- name: Add private key authentication for user netop + iosxr_user: + name: netop + state: present + public_key_contents: "{{ lookup('file', '/home/netop/.ssh/id_rsa.pub' }}" """ RETURN = """ @@ -121,6 +168,18 @@ from ansible.module_utils.network_common import remove_default_spec from ansible.module_utils.iosxr import get_config, load_config from ansible.module_utils.iosxr import iosxr_argument_spec, check_args +try: + from base64 import b64decode + HAS_B64 = True +except ImportError: + HAS_B64 = False + +try: + import paramiko + HAS_PARAMIKO = True +except ImportError: + HAS_PARAMIKO = False + def search_obj_in_list(name, lst): for o in lst: @@ -150,6 +209,9 @@ def map_obj_to_commands(updates, module): commands.append(user_cmd + ' secret ' + w['configured_password']) if w['group']: commands.append(user_cmd + ' group ' + w['group']) + elif w['groups']: + for group in w['groups']: + commands.append(user_cmd + ' group ' + group) elif state == 'present' and obj_in_have: user_cmd = 'username ' + name @@ -158,6 +220,9 @@ def map_obj_to_commands(updates, module): commands.append(user_cmd + ' secret ' + w['configured_password']) if w['group'] and w['group'] != obj_in_have['group']: commands.append(user_cmd + ' group ' + w['group']) + elif w['groups']: + for group in w['groups']: + commands.append(user_cmd + ' group ' + group) return commands @@ -215,6 +280,7 @@ def get_param_value(key, item, module): def map_params_to_obj(module): users = module.params['aggregate'] + if not users: if not module.params['name'] and module.params['purge']: return list() @@ -238,12 +304,100 @@ def map_params_to_obj(module): get_value = partial(get_param_value, item=item, module=module) item['configured_password'] = get_value('configured_password') item['group'] = get_value('group') + item['groups'] = get_value('groups') item['state'] = get_value('state') objects.append(item) return objects +def convert_key_to_base64(module): + """ IOS-XR only accepts base64 decoded files, this converts the public key to a temp file. + """ + if module.params['aggregate']: + name = 'aggregate' + else: + name = module.params['name'] + + if module.params['public_key_contents']: + key = module.params['public_key_contents'] + elif module.params['public_key']: + readfile = open(module.params['public_key'], 'r') + key = readfile.read() + splitfile = key.split()[1] + + base64key = b64decode(splitfile) + base64file = open('/tmp/publickey_%s.b64' % (name), 'w') + base64file.write(base64key) + base64file.close() + + return '/tmp/publickey_%s.b64' % (name) + + +def copy_key_to_node(module, base64keyfile): + """ Copy key to IOS-XR node. We use SFTP because older IOS-XR versions don't handle SCP very well. + """ + if (module.params['host'] is None or module.params['provider']['host'] is None): + return False + + if (module.params['username'] is None or module.params['provider']['username'] is None): + return False + + if module.params['aggregate']: + name = 'aggregate' + else: + name = module.params['name'] + + src = base64keyfile + dst = '/harddisk:/publickey_%s.b64' % (name) + + user = module.params['username'] or module.params['provider']['username'] + node = module.params['host'] or module.params['provider']['host'] + password = module.params['password'] or module.params['provider']['password'] + ssh_keyfile = module.params['ssh_keyfile'] or module.params['provider']['ssh_keyfile'] + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + if not ssh_keyfile: + ssh.connect(node, username=user, password=password) + else: + ssh.connect(node, username=user, allow_agent=True) + sftp = ssh.open_sftp() + sftp.put(src, dst) + sftp.close() + ssh.close() + + +def addremovekey(module, command): + """ Add or remove key based on command + """ + if (module.params['host'] is None or module.params['provider']['host'] is None): + return False + + if (module.params['username'] is None or module.params['provider']['username'] is None): + return False + + user = module.params['username'] or module.params['provider']['username'] + node = module.params['host'] or module.params['provider']['host'] + password = module.params['password'] or module.params['provider']['password'] + ssh_keyfile = module.params['ssh_keyfile'] or module.params['provider']['ssh_keyfile'] + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + if not ssh_keyfile: + ssh.connect(node, username=user, password=password) + else: + ssh.connect(node, username=user, allow_agent=True) + ssh_stdin, ssh_stdout, ssh_stderr = ssh.exec_command('%s \r' % (command)) + readmsg = ssh_stdout.read(100) # We need to read a bit to actually apply for some reason + if ('already' in readmsg) or ('removed' in readmsg) or ('really' in readmsg): + ssh_stdin.write('yes\r') + ssh_stdout.read(1) # We need to read a bit to actually apply for some reason + ssh.close() + + return readmsg + + def main(): """ main entry point for module execution """ @@ -253,7 +407,12 @@ def main(): configured_password=dict(no_log=True), update_password=dict(default='always', choices=['on_create', 'always']), + public_key=dict(), + public_key_contents=dict(), + group=dict(aliases=['role']), + groups=dict(type='list', elements='dict'), + state=dict(default='present', choices=['present', 'absent']) ) aggregate_spec = deepcopy(element_spec) @@ -269,12 +428,24 @@ def main(): argument_spec.update(element_spec) argument_spec.update(iosxr_argument_spec) - mutually_exclusive = [('name', 'aggregate')] + mutually_exclusive = [('name', 'aggregate'), ('public_key', 'public_key_contents'), ('group', 'groups')] module = AnsibleModule(argument_spec=argument_spec, mutually_exclusive=mutually_exclusive, supports_check_mode=True) + if (module.params['public_key_contents'] or module.params['public_key']): + if not HAS_B64: + module.fail_json( + msg='library base64 is required but does not appear to be ' + 'installed. It can be installed using `pip install base64`' + ) + if not HAS_PARAMIKO: + module.fail_json( + msg='library paramiko is required but does not appear to be ' + 'installed. It can be installed using `pip install paramiko`' + ) + warnings = list() if module.params['password'] and not module.params['configured_password']: warnings.append( @@ -309,6 +480,44 @@ def main(): load_config(module, commands, result['warnings'], commit=True) result['changed'] = True + if module.params['state'] == 'present' and (module.params['public_key_contents'] or module.params['public_key']): + if not module.check_mode: + key = convert_key_to_base64(module) + copykeys = copy_key_to_node(module, key) + if copykeys is False: + warnings.append('Please set up your provider before running this playbook') + + if module.params['aggregate']: + for user in module.params['aggregate']: + cmdtodo = "admin crypto key import authentication rsa username %s harddisk:/publickey_aggregate.b64" % (user) + addremove = addremovekey(module, cmdtodo) + if addremove is False: + warnings.append('Please set up your provider before running this playbook') + else: + cmdtodo = "admin crypto key import authentication rsa username %s harddisk:/publickey_%s.b64" % (module.params['name'], module.params['name']) + addremove = addremovekey(module, cmdtodo) + if addremove is False: + warnings.append('Please set up your provider before running this playbook') + elif module.params['state'] == 'absent': + if not module.check_mode: + if module.params['aggregate']: + for user in module.params['aggregate']: + cmdtodo = "admin crypto key zeroize authentication rsa username %s" % (user) + addremove = addremovekey(module, cmdtodo) + if addremove is False: + warnings.append('Please set up your provider before running this playbook') + else: + cmdtodo = "admin crypto key zeroize authentication rsa username %s" % (module.params['name']) + addremove = addremovekey(module, cmdtodo) + if addremove is False: + warnings.append('Please set up your provider before running this playbook') + elif module.params['purge'] is True: + if not module.check_mode: + cmdtodo = "admin crypto key zeroize authentication rsa all" + addremove = addremovekey(module, cmdtodo) + if addremove is False: + warnings.append('Please set up your provider before running this playbook') + module.exit_json(**result) if __name__ == '__main__': diff --git a/test/integration/targets/iosxr_user/files/private b/test/integration/targets/iosxr_user/files/private new file mode 100644 index 0000000000..bf2425bb88 --- /dev/null +++ b/test/integration/targets/iosxr_user/files/private @@ -0,0 +1,30 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: DES-EDE3-CBC,A823A6B5ED873917 + +mLZ1xM1+xwutkRy+K/c9QsstDPQ9F6UWtDpoYyIgs7n9VgMjhIMbWQC9CkTvnFJM +ey+iwGdQZZOThwxalm+k3pMibwRjhnF+PNFhiVkzWH8/K8QvXRQiW/vYmE/QB9pY +T0IWbMcC7/ktEfQn+6GLXoe/L7yH+aNv/2Flsa2jN2cfSXpzbneUA06/LVVOw6E+ +C74NKRWUmMPA39Zd4WOeBoWUdS5Kgwl57SOtrKs1LIGh33+TPu+Go8gJ7h/t/kaN +kverVSz+0eeX+exKumejfo1UfosplRhcjRG8YgiQ8l7SN3NBF/gXiiSrH3fLwmRJ +hbokJ8TmCozrYBs1MNe3LoU2iuIqVnJ5Sd6DJELs6vCuFz+v6J/s80NaaYMlBCbB +1lahelYqoyLb4uiDd4zQSpaxzO+Cx/d50Wpee8mFxbAL/YxacOzD3b/VCBgB+AZN +TTHr1ayd+ITd8gewXAyERKWyrDcC2beJI0fOil23PYowWvEncS6I1f4hKQY28sRf +vHSbwQdltky/xiib2/feQTaMSQFvsY67uTHipMwl5wJNOKcbeqDVMWPYST3XUsBg +LRlbT+VTUEehbOJAJ6Hh7Yv4nqu7fEh95HUQK7Ed56rMLKpmdorYO49JtewkEUsj +LJn7tcxMUuOcWKHMPu6vB/63f6Ulthqp1SEG8aNBaZMuPyLWAPAJc2okOmkiSbvO +0Hxe6BtAGn2fUo2jK6E3tD/dsIR2qqMlL09FkACGT8D5Lfh5d3z+lo9DxpXl281R +ablehPyHgHcIC6cD2/7FwwjzUuyj/kYcETnMs51agcWFAXTom/ehqD+IQ8jZ73zT +5O4FFgslnNmB/vddh9PeYpjDYdR4y5xMrlMxJ+qcZuQOq7dfaiodq8oj+XPmwgxA +audX/sHMutOpmOagrsQfaQXaPqRXdQTnuwHacQfwq+tBBhrft5gwt1HE7Ir2ulwD +Q19kefchkJu/0c1cAGg1VHtQic0a6tX6PrwqZOMDfpSywcImMCF4KHgD2EC5/8h6 +tq0PqPLNcwiM2NhpypCuYmkYZ0gnJ/xAwtM85Ck9nmPFptLSd0b7YB7dtGsFYY5A +rhIcq5lZhy06/RRAPluIkniscA50iEO/EXKwzYzovBJh6jQz7oYsbEUW5kwg0gm/ +YPSa6lqv2kTpXS+UiGyeNWdUkr5DpdwKe4lrAsN94HE9/SoLgFvz0X5/WyTssSzo +IO3WfLfBc7SOkZK1ibcleIqilzd+LSoIqqGrft2yonXgJD3p9xO+Hlldczx2kHmu +z4lZBq53AkVAQ4os5L7ZRnmxoqKn2XAQRwVH3M9ZFYFEqEyDmZhlFdJSGEnKws81 +Ej48t6KWwqml02cx675bSYI22tL3+RL7AGmlC0/Xh8wIVesgulsYmnhW4BtpBYf2 +fwv5esJJMjkh2LvLNG3edYChugudeZXtcBJdNr0GYRbBAhvO25bRcr6z8nYDusKX +e/+30vATOcBO/zaOYIwDGT5ZwMQAV1aQl8HyeyYESNjb0fBXQ3OYObOrTTs8MLyC +I4b6wr1vlbN+lMOm+RIXCDgmC3COdlgCHyo3qiIu2YNYQVoNF4NN4A== +-----END RSA PRIVATE KEY----- diff --git a/test/integration/targets/iosxr_user/files/public.pub b/test/integration/targets/iosxr_user/files/public.pub new file mode 100644 index 0000000000..db1847f45f --- /dev/null +++ b/test/integration/targets/iosxr_user/files/public.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAQEAkvLTTJdwZ0lg1cUCn13Hi3+ho2+G6/96XuAP7jA7Ghz9NPbC/eqXnjvb27BA8CxtFXYuXR5eZWSq2UN5zFcfrFb57XFxdAg2q21hGEX+FGiTUuRZh8+ByVEh0LUetFTwsEZ1iGv6GZiLBt7IJvClXbyNTJEt3DZncHfGwudyGFviV4dGrzusDAGAcoHqvD/5uXYl4PjMH9oSfraO3sG4Q7soQwxNeiM8qOLf3c1SabHBAtSewwnA0E/jhzpOLD2QUncU5s+Oa9PvEXXhGv5eZo9lp71brsgyWj32m2UuXx/n+EZg78GVJT5mFO7LG239n3gTnwkMVdr6zVBFNX5Mvw== rsa-key-20171025 diff --git a/test/integration/targets/iosxr_user/files/public2.pub b/test/integration/targets/iosxr_user/files/public2.pub new file mode 100644 index 0000000000..2fc645683e --- /dev/null +++ b/test/integration/targets/iosxr_user/files/public2.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAQEAhTxbibM8hKZn7xDURs15L3gkcsnpDoZ+tNm5zpP9dcboASnIyJzfC7J/RdRCQsO/pDmUY4y/tsTx18uenyfazxtNkyCHdANlp8XVF1fGNv5GM+QbsDqxe54sdG9csASX0/Ljvl538IbcLFVH0zxyKspbDOgkAkUSuKIAH5x+/GhkAoGQO2tOhYjqofNtUxLSvfRsf4Gm1M0WgdWmz3MW4NOdZhsL4S+STgRPU1jy1dKGj7BKY9cpnCWBFHa2wSaOXJEBZEKNaFVxlBBrFs5brjRQA0mVPmE+pz+/+IJeSNEEma9cXur0ONeb6OoXvkManxKfkaswT2ybOChAzJR8dQ== T-MOBILE \ No newline at end of file diff --git a/test/integration/targets/iosxr_user/tests/cli/auth.yaml b/test/integration/targets/iosxr_user/tests/cli/auth.yaml index f9a186a592..9c360d2413 100644 --- a/test/integration/targets/iosxr_user/tests/cli/auth.yaml +++ b/test/integration/targets/iosxr_user/tests/cli/auth.yaml @@ -25,6 +25,62 @@ that: - results.failed + - name: create user with private key (contents input) + iosxr_user: + name: auth_user + state: present + public_key_contents: "{{ lookup('file', \"{{ output_dir }}/public.pub\") }}" + + - name: test login with private key + expect: + command: "ssh auth_user@{{ ansible_ssh_host }} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {{output_dir}}/private show version" + responses: + (?i)passphrase: 'pass123' + + - name: remove user and key + iosxr_user: + name: auth_user + state: absent + + - name: test login with private key (should fail, no user) + expect: + command: "ssh auth_user@{{ ansible_ssh_host }} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {{output_dir}}/private show version" + responses: + (?i)passphrase: 'pass123' + ignore_errors: yes + register: results + + - name: create user with private key (path input) + iosxr_user: + name: auth_user + state: present + public_key: "{{ output_dir }}/public.pub" + + - name: test login with private key + expect: + command: "ssh auth_user@{{ ansible_ssh_host }} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {{output_dir}}/private show version" + responses: + (?i)passphrase: 'pass123' + + - name: change private key for user + iosxr_user: + name: auth_user + state: present + public_key_contents: "{{ lookup('file', \"{{ output_dir }}/public2.pub\") }}" + + - name: test login with invalid private key (should fail) + expect: + command: "ssh auth_user@{{ ansible_ssh_host }} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {{output_dir}}/private show version" + responses: + (?i)passphrase: "pass123" + ignore_errors: yes + register: results + + - name: check that attempt failed + assert: + that: + - results.failed + always: - name: delete user iosxr_user: