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