1#!/usr/bin/python
2# -*- coding: utf-8 -*-
3
4# (c) 2017, Ansible by Red Hat, inc
5#
6# This file is part of Ansible by Red Hat
7#
8# Ansible is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# Ansible is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License
19# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
20#
21
22ANSIBLE_METADATA = {'metadata_version': '1.1',
23                    'status': ['preview'],
24                    'supported_by': 'network'}
25
26DOCUMENTATION = """
27---
28module: ios_user
29version_added: "2.4"
30author: "Trishna Guha (@trishnaguha)"
31short_description: Manage the aggregate of local users on Cisco IOS device
32description:
33  - This module provides declarative management of the local usernames
34    configured on network devices. It allows playbooks to manage
35    either individual usernames or the aggregate of usernames in the
36    current running config. It also supports purging usernames from the
37    configuration that are not explicitly defined.
38notes:
39  - Tested against IOS 15.6
40options:
41  aggregate:
42    description:
43      - The set of username objects to be configured on the remote
44        Cisco IOS device. The list entries can either be the username
45        or a hash of username and properties. This argument is mutually
46        exclusive with the C(name) argument.
47    aliases: ['users', 'collection']
48  name:
49    description:
50      - The username to be configured on the Cisco IOS device.
51        This argument accepts a string value and is mutually exclusive
52        with the C(aggregate) argument.
53        Please note that this option is not same as C(provider username).
54  configured_password:
55    description:
56      - The password to be configured on the Cisco IOS device. The
57        password needs to be provided in clear and it will be encrypted
58        on the device.
59        Please note that this option is not same as C(provider password).
60  update_password:
61    description:
62      - Since passwords are encrypted in the device running config, this
63        argument will instruct the module when to change the password.  When
64        set to C(always), the password will always be updated in the device
65        and when set to C(on_create) the password will be updated only if
66        the username is created.
67    default: always
68    choices: ['on_create', 'always']
69  password_type:
70    description:
71      - This argument determines whether a 'password' or 'secret' will be
72        configured.
73    default: secret
74    choices: ['secret', 'password']
75    version_added: "2.8"
76  hashed_password:
77    description:
78      - This option allows configuring hashed passwords on Cisco IOS devices.
79    suboptions:
80      type:
81        description:
82          - Specifies the type of hash (e.g., 5 for MD5, 8 for PBKDF2, etc.)
83          - For this to work, the device needs to support the desired hash type
84        type: int
85        required: True
86      value:
87        description:
88          - The actual hashed password to be configured on the device
89        required: True
90    version_added: "2.8"
91  privilege:
92    description:
93      - The C(privilege) argument configures the privilege level of the
94        user when logged into the system. This argument accepts integer
95        values in the range of 1 to 15.
96  view:
97    description:
98      - Configures the view for the username in the
99        device running configuration. The argument accepts a string value
100        defining the view name. This argument does not check if the view
101        has been configured on the device.
102    aliases: ['role']
103  sshkey:
104    description:
105      - Specifies one or more SSH public key(s) to configure
106        for the given username.
107      - This argument accepts a valid SSH key value.
108    version_added: "2.7"
109  nopassword:
110    description:
111      - Defines the username without assigning
112        a password. This will allow the user to login to the system
113        without being authenticated by a password.
114    type: bool
115  purge:
116    description:
117      - Instructs the module to consider the
118        resource definition absolute. It will remove any previously
119        configured usernames on the device with the exception of the
120        `admin` user (the current defined set of users).
121    type: bool
122    default: false
123  state:
124    description:
125      - Configures the state of the username definition
126        as it relates to the device operational configuration. When set
127        to I(present), the username(s) should be configured in the device active
128        configuration and when set to I(absent) the username(s) should not be
129        in the device active configuration
130    default: present
131    choices: ['present', 'absent']
132extends_documentation_fragment: ios
133"""
134
135EXAMPLES = """
136- name: create a new user
137  ios_user:
138    name: ansible
139    nopassword: True
140    sshkey: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
141    state: present
142
143- name: create a new user with multiple keys
144  ios_user:
145    name: ansible
146    sshkey:
147      - "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
148      - "{{ lookup('file', '~/path/to/public_key') }}"
149    state: present
150
151- name: remove all users except admin
152  ios_user:
153    purge: yes
154
155- name: remove all users except admin and these listed users
156  ios_user:
157    aggregate:
158      - name: testuser1
159      - name: testuser2
160      - name: testuser3
161    purge: yes
162
163- name: set multiple users to privilege level 15
164  ios_user:
165    aggregate:
166      - name: netop
167      - name: netend
168    privilege: 15
169    state: present
170
171- name: set user view/role
172  ios_user:
173    name: netop
174    view: network-operator
175    state: present
176
177- name: Change Password for User netop
178  ios_user:
179    name: netop
180    configured_password: "{{ new_password }}"
181    update_password: always
182    state: present
183
184- name: Aggregate of users
185  ios_user:
186    aggregate:
187      - name: ansibletest2
188      - name: ansibletest3
189    view: network-admin
190
191- name: Add a user specifying password type
192  ios_user:
193    name: ansibletest4
194    configured_password: "{{ new_password }}"
195    password_type: password
196
197- name: Add a user with MD5 hashed password
198  ios_user:
199    name: ansibletest5
200    hashed_password:
201      type: 5
202      value: $3$8JcDilcYgFZi.yz4ApaqkHG2.8/
203
204- name: Delete users with aggregate
205  ios_user:
206    aggregate:
207      - name: ansibletest1
208      - name: ansibletest2
209      - name: ansibletest3
210    state: absent
211"""
212
213RETURN = """
214commands:
215  description: The list of configuration mode commands to send to the device
216  returned: always
217  type: list
218  sample:
219    - username ansible secret password
220    - username admin secret admin
221"""
222import base64
223import hashlib
224import re
225from copy import deepcopy
226from functools import partial
227
228from ansible.module_utils.basic import AnsibleModule
229from ansible.module_utils.network.common.utils import remove_default_spec
230from ansible.module_utils.network.ios.ios import get_config, load_config
231from ansible.module_utils.network.ios.ios import ios_argument_spec, check_args
232from ansible.module_utils.six import iteritems
233
234
235def validate_privilege(value, module):
236    if value and not 1 <= value <= 15:
237        module.fail_json(msg='privilege must be between 1 and 15, got %s' % value)
238
239
240def user_del_cmd(username):
241    return {
242        'command': 'no username %s' % username,
243        'prompt': 'This operation will remove all username related configurations with same name',
244        'answer': 'y',
245        'newline': False,
246    }
247
248
249def sshkey_fingerprint(sshkey):
250    # IOS will accept a MD5 fingerprint of the public key
251    # and is easier to configure in a single line
252    # we calculate this fingerprint here
253    if not sshkey:
254        return None
255    if ' ' in sshkey:
256        # ssh-rsa AAA...== comment
257        keyparts = sshkey.split(' ')
258        keyparts[1] = hashlib.md5(base64.b64decode(keyparts[1])).hexdigest().upper()
259        return ' '.join(keyparts)
260    else:
261        # just the key, assume rsa type
262        return 'ssh-rsa %s' % hashlib.md5(base64.b64decode(sshkey)).hexdigest().upper()
263
264
265def map_obj_to_commands(updates, module):
266    commands = list()
267    update_password = module.params['update_password']
268    password_type = module.params['password_type']
269
270    def needs_update(want, have, x):
271        return want.get(x) and (want.get(x) != have.get(x))
272
273    def add(command, want, x):
274        command.append('username %s %s' % (want['name'], x))
275
276    def add_hashed_password(command, want, x):
277        command.append('username %s secret %s %s' % (want['name'], x.get('type'),
278                                                     x.get('value')))
279
280    def add_ssh(command, want, x=None):
281        command.append('ip ssh pubkey-chain')
282        if x:
283            command.append('username %s' % want['name'])
284            for item in x:
285                command.append('key-hash %s' % item)
286            command.append('exit')
287        else:
288            command.append('no username %s' % want['name'])
289        command.append('exit')
290
291    for update in updates:
292        want, have = update
293
294        if want['state'] == 'absent':
295            if have['sshkey']:
296                add_ssh(commands, want)
297            else:
298                commands.append(user_del_cmd(want['name']))
299
300        if needs_update(want, have, 'view'):
301            add(commands, want, 'view %s' % want['view'])
302
303        if needs_update(want, have, 'privilege'):
304            add(commands, want, 'privilege %s' % want['privilege'])
305
306        if needs_update(want, have, 'sshkey'):
307            add_ssh(commands, want, want['sshkey'])
308
309        if needs_update(want, have, 'configured_password'):
310            if update_password == 'always' or not have:
311                if have and password_type != have['password_type']:
312                    module.fail_json(msg='Can not have both a user password and a user secret.' +
313                                         ' Please choose one or the other.')
314                add(commands, want, '%s %s' % (password_type, want['configured_password']))
315
316        if needs_update(want, have, 'hashed_password'):
317            add_hashed_password(commands, want, want['hashed_password'])
318
319        if needs_update(want, have, 'nopassword'):
320            if want['nopassword']:
321                add(commands, want, 'nopassword')
322            else:
323                add(commands, want, user_del_cmd(want['name']))
324
325    return commands
326
327
328def parse_view(data):
329    match = re.search(r'view (\S+)', data, re.M)
330    if match:
331        return match.group(1)
332
333
334def parse_sshkey(data, user):
335    sshregex = r'username %s(\n\s+key-hash .+$)+' % user
336    sshcfg = re.search(sshregex, data, re.M)
337    key_list = []
338    if sshcfg:
339        match = re.findall(r'key-hash (\S+ \S+(?: .+)?)$', sshcfg.group(), re.M)
340        if match:
341            key_list = match
342    return key_list
343
344
345def parse_privilege(data):
346    match = re.search(r'privilege (\S+)', data, re.M)
347    if match:
348        return int(match.group(1))
349
350
351def parse_password_type(data):
352    type = None
353    if data and data.split()[-3] in ['password', 'secret']:
354        type = data.split()[-3]
355    return type
356
357
358def map_config_to_obj(module):
359    data = get_config(module, flags=['| section username'])
360
361    match = re.findall(r'(?:^(?:u|\s{2}u))sername (\S+)', data, re.M)
362    if not match:
363        return list()
364
365    instances = list()
366
367    for user in set(match):
368        regex = r'username %s .+$' % user
369        cfg = re.findall(regex, data, re.M)
370        cfg = '\n'.join(cfg)
371        obj = {
372            'name': user,
373            'state': 'present',
374            'nopassword': 'nopassword' in cfg,
375            'configured_password': None,
376            'hashed_password': None,
377            'password_type': parse_password_type(cfg),
378            'sshkey': parse_sshkey(data, user),
379            'privilege': parse_privilege(cfg),
380            'view': parse_view(cfg)
381        }
382        instances.append(obj)
383
384    return instances
385
386
387def get_param_value(key, item, module):
388    # if key doesn't exist in the item, get it from module.params
389    if not item.get(key):
390        value = module.params[key]
391
392    # if key does exist, do a type check on it to validate it
393    else:
394        value_type = module.argument_spec[key].get('type', 'str')
395        type_checker = module._CHECK_ARGUMENT_TYPES_DISPATCHER[value_type]
396        type_checker(item[key])
397        value = item[key]
398
399    # validate the param value (if validator func exists)
400    validator = globals().get('validate_%s' % key)
401    if all((value, validator)):
402        validator(value, module)
403
404    return value
405
406
407def map_params_to_obj(module):
408    users = module.params['aggregate']
409    if not users:
410        if not module.params['name'] and module.params['purge']:
411            return list()
412        elif not module.params['name']:
413            module.fail_json(msg='username is required')
414        else:
415            aggregate = [{'name': module.params['name']}]
416    else:
417        aggregate = list()
418        for item in users:
419            if not isinstance(item, dict):
420                aggregate.append({'name': item})
421            elif 'name' not in item:
422                module.fail_json(msg='name is required')
423            else:
424                aggregate.append(item)
425
426    objects = list()
427
428    for item in aggregate:
429        get_value = partial(get_param_value, item=item, module=module)
430        item['configured_password'] = get_value('configured_password')
431        item['hashed_password'] = get_value('hashed_password')
432        item['nopassword'] = get_value('nopassword')
433        item['privilege'] = get_value('privilege')
434        item['view'] = get_value('view')
435        item['sshkey'] = render_key_list(get_value('sshkey'))
436        item['state'] = get_value('state')
437        objects.append(item)
438
439    return objects
440
441
442def render_key_list(ssh_keys):
443    key_list = []
444    if ssh_keys:
445        for item in ssh_keys:
446            key_list.append(sshkey_fingerprint(item))
447    return key_list
448
449
450def update_objects(want, have):
451    updates = list()
452    for entry in want:
453        item = next((i for i in have if i['name'] == entry['name']), None)
454        if all((item is None, entry['state'] == 'present')):
455            updates.append((entry, {}))
456        elif item:
457            for key, value in iteritems(entry):
458                if value and value != item[key]:
459                    updates.append((entry, item))
460    return updates
461
462
463def main():
464    """ main entry point for module execution
465    """
466    hashed_password_spec = dict(
467        type=dict(type='int', required=True),
468        value=dict(no_log=True, required=True)
469    )
470
471    element_spec = dict(
472        name=dict(),
473
474        configured_password=dict(no_log=True),
475        hashed_password=dict(no_log=True, type='dict', options=hashed_password_spec),
476        nopassword=dict(type='bool'),
477        update_password=dict(default='always', choices=['on_create', 'always']),
478        password_type=dict(default='secret', choices=['secret', 'password']),
479
480        privilege=dict(type='int'),
481        view=dict(aliases=['role']),
482
483        sshkey=dict(type='list'),
484
485        state=dict(default='present', choices=['present', 'absent'])
486    )
487    aggregate_spec = deepcopy(element_spec)
488    aggregate_spec['name'] = dict(required=True)
489
490    # remove default in aggregate spec, to handle common arguments
491    remove_default_spec(aggregate_spec)
492
493    argument_spec = dict(
494        aggregate=dict(type='list', elements='dict', options=aggregate_spec, aliases=['users', 'collection']),
495        purge=dict(type='bool', default=False)
496    )
497
498    argument_spec.update(element_spec)
499    argument_spec.update(ios_argument_spec)
500
501    mutually_exclusive = [('name', 'aggregate'), ('nopassword', 'hashed_password', 'configured_password')]
502
503    module = AnsibleModule(argument_spec=argument_spec,
504                           mutually_exclusive=mutually_exclusive,
505                           supports_check_mode=True)
506
507    warnings = list()
508    if module.params['password'] and not module.params['configured_password']:
509        warnings.append(
510            'The "password" argument is used to authenticate the current connection. ' +
511            'To set a user password use "configured_password" instead.'
512        )
513
514    check_args(module, warnings)
515
516    result = {'changed': False}
517    if warnings:
518        result['warnings'] = warnings
519
520    want = map_params_to_obj(module)
521    have = map_config_to_obj(module)
522
523    commands = map_obj_to_commands(update_objects(want, have), module)
524
525    if module.params['purge']:
526        want_users = [x['name'] for x in want]
527        have_users = [x['name'] for x in have]
528        for item in set(have_users).difference(want_users):
529            if item != 'admin':
530                commands.append(user_del_cmd(item))
531
532    result['commands'] = commands
533
534    if commands:
535        if not module.check_mode:
536            load_config(module, commands)
537        result['changed'] = True
538
539    module.exit_json(**result)
540
541
542if __name__ == '__main__':
543    main()
544