1#!/usr/local/bin/python3.8 2# -*- coding: utf-8 -*- 3 4# Copyright: (c) 2012, Brad Olson <brado@movedbylight.com> 5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 7from __future__ import absolute_import, division, print_function 8__metaclass__ = type 9 10 11DOCUMENTATION = r''' 12--- 13module: authorized_key 14short_description: Adds or removes an SSH authorized key 15description: 16 - Adds or removes SSH authorized keys for particular user accounts. 17version_added: "1.0.0" 18options: 19 user: 20 description: 21 - The username on the remote host whose authorized_keys file will be modified. 22 type: str 23 required: true 24 key: 25 description: 26 - The SSH public key(s), as a string or (since Ansible 1.9) url (https://github.com/username.keys). 27 type: str 28 required: true 29 path: 30 description: 31 - Alternate path to the authorized_keys file. 32 - When unset, this value defaults to I(~/.ssh/authorized_keys). 33 type: path 34 manage_dir: 35 description: 36 - Whether this module should manage the directory of the authorized key file. 37 - If set to C(yes), the module will create the directory, as well as set the owner and permissions 38 of an existing directory. 39 - Be sure to set C(manage_dir=no) if you are using an alternate directory for authorized_keys, 40 as set with C(path), since you could lock yourself out of SSH access. 41 - See the example below. 42 type: bool 43 default: yes 44 state: 45 description: 46 - Whether the given key (with the given key_options) should or should not be in the file. 47 type: str 48 choices: [ absent, present ] 49 default: present 50 key_options: 51 description: 52 - A string of ssh key options to be prepended to the key in the authorized_keys file. 53 type: str 54 exclusive: 55 description: 56 - Whether to remove all other non-specified keys from the authorized_keys file. 57 - Multiple keys can be specified in a single C(key) string value by separating them by newlines. 58 - This option is not loop aware, so if you use C(with_) , it will be exclusive per iteration of the loop. 59 - If you want multiple keys in the file you need to pass them all to C(key) in a single batch as mentioned above. 60 type: bool 61 default: no 62 validate_certs: 63 description: 64 - This only applies if using a https url as the source of the keys. 65 - If set to C(no), the SSL certificates will not be validated. 66 - This should only set to C(no) used on personally controlled sites using self-signed certificates as it avoids verifying the source site. 67 - Prior to 2.1 the code worked as if this was set to C(yes). 68 type: bool 69 default: yes 70 comment: 71 description: 72 - Change the comment on the public key. 73 - Rewriting the comment is useful in cases such as fetching it from GitHub or GitLab. 74 - If no comment is specified, the existing comment will be kept. 75 type: str 76 follow: 77 description: 78 - Follow path symlink instead of replacing it. 79 type: bool 80 default: no 81author: Ansible Core Team 82''' 83 84EXAMPLES = r''' 85- name: Set authorized key taken from file 86 ansible.posix.authorized_key: 87 user: charlie 88 state: present 89 key: "{{ lookup('file', '/home/charlie/.ssh/id_rsa.pub') }}" 90 91- name: Set authorized keys taken from url 92 ansible.posix.authorized_key: 93 user: charlie 94 state: present 95 key: https://github.com/charlie.keys 96 97- name: Set authorized key in alternate location 98 ansible.posix.authorized_key: 99 user: charlie 100 state: present 101 key: "{{ lookup('file', '/home/charlie/.ssh/id_rsa.pub') }}" 102 path: /etc/ssh/authorized_keys/charlie 103 manage_dir: False 104 105- name: Set up multiple authorized keys 106 ansible.posix.authorized_key: 107 user: deploy 108 state: present 109 key: '{{ item }}' 110 with_file: 111 - public_keys/doe-jane 112 - public_keys/doe-john 113 114- name: Set authorized key defining key options 115 ansible.posix.authorized_key: 116 user: charlie 117 state: present 118 key: "{{ lookup('file', '/home/charlie/.ssh/id_rsa.pub') }}" 119 key_options: 'no-port-forwarding,from="10.0.1.1"' 120 121- name: Set authorized key without validating the TLS/SSL certificates 122 ansible.posix.authorized_key: 123 user: charlie 124 state: present 125 key: https://github.com/user.keys 126 validate_certs: False 127 128- name: Set authorized key, removing all the authorized keys already set 129 ansible.posix.authorized_key: 130 user: root 131 key: "{{ lookup('file', 'public_keys/doe-jane') }}" 132 state: present 133 exclusive: True 134 135- name: Set authorized key for user ubuntu copying it from current user 136 ansible.posix.authorized_key: 137 user: ubuntu 138 state: present 139 key: "{{ lookup('file', lookup('env','HOME') + '/.ssh/id_rsa.pub') }}" 140''' 141 142RETURN = r''' 143exclusive: 144 description: If the key has been forced to be exclusive or not. 145 returned: success 146 type: bool 147 sample: False 148key: 149 description: The key that the module was running against. 150 returned: success 151 type: str 152 sample: https://github.com/user.keys 153key_option: 154 description: Key options related to the key. 155 returned: success 156 type: str 157 sample: null 158keyfile: 159 description: Path for authorized key file. 160 returned: success 161 type: str 162 sample: /home/user/.ssh/authorized_keys 163manage_dir: 164 description: Whether this module managed the directory of the authorized key file. 165 returned: success 166 type: bool 167 sample: True 168path: 169 description: Alternate path to the authorized_keys file 170 returned: success 171 type: str 172 sample: null 173state: 174 description: Whether the given key (with the given key_options) should or should not be in the file 175 returned: success 176 type: str 177 sample: present 178unique: 179 description: Whether the key is unique 180 returned: success 181 type: bool 182 sample: false 183user: 184 description: The username on the remote host whose authorized_keys file will be modified 185 returned: success 186 type: str 187 sample: user 188validate_certs: 189 description: This only applies if using a https url as the source of the keys. If set to C(no), the SSL certificates will not be validated. 190 returned: success 191 type: bool 192 sample: true 193''' 194 195# Makes sure the public key line is present or absent in the user's .ssh/authorized_keys. 196# 197# Arguments 198# ========= 199# user = username 200# key = line to add to authorized_keys for user 201# path = path to the user's authorized_keys file (default: ~/.ssh/authorized_keys) 202# manage_dir = whether to create, and control ownership of the directory (default: true) 203# state = absent|present (default: present) 204# 205# see example in examples/playbooks 206 207import os 208import pwd 209import os.path 210import tempfile 211import re 212import shlex 213from operator import itemgetter 214 215from ansible.module_utils._text import to_native 216from ansible.module_utils.basic import AnsibleModule 217from ansible.module_utils.urls import fetch_url 218 219 220class keydict(dict): 221 222 """ a dictionary that maintains the order of keys as they are added 223 224 This has become an abuse of the dict interface. Probably should be 225 rewritten to be an entirely custom object with methods instead of 226 bracket-notation. 227 228 Our requirements are for a data structure that: 229 * Preserves insertion order 230 * Can store multiple values for a single key. 231 232 The present implementation has the following functions used by the rest of 233 the code: 234 235 * __setitem__(): to add a key=value. The value can never be disassociated 236 with the key, only new values can be added in addition. 237 * items(): to retrieve the key, value pairs. 238 239 Other dict methods should work but may be surprising. For instance, there 240 will be multiple keys that are the same in keys() and __getitem__() will 241 return a list of the values that have been set via __setitem__. 242 """ 243 244 # http://stackoverflow.com/questions/2328235/pythonextend-the-dict-class 245 246 def __init__(self, *args, **kw): 247 super(keydict, self).__init__(*args, **kw) 248 self.itemlist = list(super(keydict, self).keys()) 249 250 def __setitem__(self, key, value): 251 self.itemlist.append(key) 252 if key in self: 253 self[key].append(value) 254 else: 255 super(keydict, self).__setitem__(key, [value]) 256 257 def __iter__(self): 258 return iter(self.itemlist) 259 260 def keys(self): 261 return self.itemlist 262 263 def _item_generator(self): 264 indexes = {} 265 for key in self.itemlist: 266 if key in indexes: 267 indexes[key] += 1 268 else: 269 indexes[key] = 0 270 yield key, self[key][indexes[key]] 271 272 def iteritems(self): 273 raise NotImplementedError("Do not use this as it's not available on py3") 274 275 def items(self): 276 return list(self._item_generator()) 277 278 def itervalues(self): 279 raise NotImplementedError("Do not use this as it's not available on py3") 280 281 def values(self): 282 return [item[1] for item in self.items()] 283 284 285def keyfile(module, user, write=False, path=None, manage_dir=True, follow=False): 286 """ 287 Calculate name of authorized keys file, optionally creating the 288 directories and file, properly setting permissions. 289 290 :param str user: name of user in passwd file 291 :param bool write: if True, write changes to authorized_keys file (creating directories if needed) 292 :param str path: if not None, use provided path rather than default of '~user/.ssh/authorized_keys' 293 :param bool manage_dir: if True, create and set ownership of the parent dir of the authorized_keys file 294 :param bool follow: if True symlinks will be followed and not replaced 295 :return: full path string to authorized_keys for user 296 """ 297 298 if module.check_mode and path is not None: 299 keysfile = path 300 301 if follow: 302 return os.path.realpath(keysfile) 303 304 return keysfile 305 306 try: 307 user_entry = pwd.getpwnam(user) 308 except KeyError as e: 309 if module.check_mode and path is None: 310 module.fail_json(msg="Either user must exist or you must provide full path to key file in check mode") 311 module.fail_json(msg="Failed to lookup user %s: %s" % (user, to_native(e))) 312 if path is None: 313 homedir = user_entry.pw_dir 314 sshdir = os.path.join(homedir, ".ssh") 315 keysfile = os.path.join(sshdir, "authorized_keys") 316 else: 317 sshdir = os.path.dirname(path) 318 keysfile = path 319 320 if follow: 321 keysfile = os.path.realpath(keysfile) 322 323 if not write or module.check_mode: 324 return keysfile 325 326 uid = user_entry.pw_uid 327 gid = user_entry.pw_gid 328 329 if manage_dir: 330 if not os.path.exists(sshdir): 331 try: 332 os.mkdir(sshdir, int('0700', 8)) 333 except OSError as e: 334 module.fail_json(msg="Failed to create directory %s : %s" % (sshdir, to_native(e))) 335 if module.selinux_enabled(): 336 module.set_default_selinux_context(sshdir, False) 337 os.chown(sshdir, uid, gid) 338 os.chmod(sshdir, int('0700', 8)) 339 340 if not os.path.exists(keysfile): 341 basedir = os.path.dirname(keysfile) 342 if not os.path.exists(basedir): 343 os.makedirs(basedir) 344 try: 345 f = open(keysfile, "w") # touches file so we can set ownership and perms 346 finally: 347 f.close() 348 if module.selinux_enabled(): 349 module.set_default_selinux_context(keysfile, False) 350 351 try: 352 os.chown(keysfile, uid, gid) 353 os.chmod(keysfile, int('0600', 8)) 354 except OSError: 355 pass 356 357 return keysfile 358 359 360def parseoptions(module, options): 361 ''' 362 reads a string containing ssh-key options 363 and returns a dictionary of those options 364 ''' 365 options_dict = keydict() # ordered dict 366 if options: 367 # the following regex will split on commas while 368 # ignoring those commas that fall within quotes 369 regex = re.compile(r'''((?:[^,"']|"[^"]*"|'[^']*')+)''') 370 parts = regex.split(options)[1:-1] 371 for part in parts: 372 if "=" in part: 373 (key, value) = part.split("=", 1) 374 options_dict[key] = value 375 elif part != ",": 376 options_dict[part] = None 377 378 return options_dict 379 380 381def parsekey(module, raw_key, rank=None): 382 ''' 383 parses a key, which may or may not contain a list 384 of ssh-key options at the beginning 385 386 rank indicates the keys original ordering, so that 387 it can be written out in the same order. 388 ''' 389 390 VALID_SSH2_KEY_TYPES = [ 391 'sk-ecdsa-sha2-nistp256@openssh.com', 392 'sk-ecdsa-sha2-nistp256-cert-v01@openssh.com', 393 'webauthn-sk-ecdsa-sha2-nistp256@openssh.com', 394 'ecdsa-sha2-nistp256', 395 'ecdsa-sha2-nistp256-cert-v01@openssh.com', 396 'ecdsa-sha2-nistp384', 397 'ecdsa-sha2-nistp384-cert-v01@openssh.com', 398 'ecdsa-sha2-nistp521', 399 'ecdsa-sha2-nistp521-cert-v01@openssh.com', 400 'sk-ssh-ed25519@openssh.com', 401 'sk-ssh-ed25519-cert-v01@openssh.com', 402 'ssh-ed25519', 403 'ssh-ed25519-cert-v01@openssh.com', 404 'ssh-dss', 405 'ssh-rsa', 406 'ssh-xmss@openssh.com', 407 'ssh-xmss-cert-v01@openssh.com', 408 'rsa-sha2-256', 409 'rsa-sha2-512', 410 'ssh-rsa-cert-v01@openssh.com', 411 'rsa-sha2-256-cert-v01@openssh.com', 412 'rsa-sha2-512-cert-v01@openssh.com', 413 'ssh-dss-cert-v01@openssh.com', 414 ] 415 416 options = None # connection options 417 key = None # encrypted key string 418 key_type = None # type of ssh key 419 type_index = None # index of keytype in key string|list 420 421 # remove comment yaml escapes 422 raw_key = raw_key.replace(r'\#', '#') 423 424 # split key safely 425 lex = shlex.shlex(raw_key) 426 lex.quotes = [] 427 lex.commenters = '' # keep comment hashes 428 lex.whitespace_split = True 429 key_parts = list(lex) 430 431 if key_parts and key_parts[0] == '#': 432 # comment line, invalid line, etc. 433 return (raw_key, 'skipped', None, None, rank) 434 435 for i in range(0, len(key_parts)): 436 if key_parts[i] in VALID_SSH2_KEY_TYPES: 437 type_index = i 438 key_type = key_parts[i] 439 break 440 441 # check for options 442 if type_index is None: 443 return None 444 elif type_index > 0: 445 options = " ".join(key_parts[:type_index]) 446 447 # parse the options (if any) 448 options = parseoptions(module, options) 449 450 # get key after the type index 451 key = key_parts[(type_index + 1)] 452 453 # set comment to everything after the key 454 if len(key_parts) > (type_index + 1): 455 comment = " ".join(key_parts[(type_index + 2):]) 456 457 return (key, key_type, options, comment, rank) 458 459 460def readfile(filename): 461 462 if not os.path.isfile(filename): 463 return '' 464 465 f = open(filename) 466 try: 467 return f.read() 468 finally: 469 f.close() 470 471 472def parsekeys(module, lines): 473 keys = {} 474 for rank_index, line in enumerate(lines.splitlines(True)): 475 key_data = parsekey(module, line, rank=rank_index) 476 if key_data: 477 # use key as identifier 478 keys[key_data[0]] = key_data 479 else: 480 # for an invalid line, just set the line 481 # dict key to the line so it will be re-output later 482 keys[line] = (line, 'skipped', None, None, rank_index) 483 return keys 484 485 486def writefile(module, filename, content): 487 dummy, tmp_path = tempfile.mkstemp() 488 489 try: 490 with open(tmp_path, "w") as f: 491 f.write(content) 492 except IOError as e: 493 module.add_cleanup_file(tmp_path) 494 module.fail_json(msg="Failed to write to file %s: %s" % (tmp_path, to_native(e))) 495 module.atomic_move(tmp_path, filename) 496 497 498def serialize(keys): 499 lines = [] 500 new_keys = keys.values() 501 # order the new_keys by their original ordering, via the rank item in the tuple 502 ordered_new_keys = sorted(new_keys, key=itemgetter(4)) 503 504 for key in ordered_new_keys: 505 try: 506 (keyhash, key_type, options, comment, rank) = key 507 508 option_str = "" 509 if options: 510 option_strings = [] 511 for option_key, value in options.items(): 512 if value is None: 513 option_strings.append("%s" % option_key) 514 else: 515 option_strings.append("%s=%s" % (option_key, value)) 516 option_str = ",".join(option_strings) 517 option_str += " " 518 519 # comment line or invalid line, just leave it 520 if not key_type: 521 key_line = key 522 523 if key_type == 'skipped': 524 key_line = key[0] 525 else: 526 key_line = "%s%s %s %s\n" % (option_str, key_type, keyhash, comment) 527 except Exception: 528 key_line = key 529 lines.append(key_line) 530 return ''.join(lines) 531 532 533def enforce_state(module, params): 534 """ 535 Add or remove key. 536 """ 537 538 user = params["user"] 539 key = params["key"] 540 path = params.get("path", None) 541 manage_dir = params.get("manage_dir", True) 542 state = params.get("state", "present") 543 key_options = params.get("key_options", None) 544 exclusive = params.get("exclusive", False) 545 comment = params.get("comment", None) 546 follow = params.get('follow', False) 547 error_msg = "Error getting key from: %s" 548 549 # if the key is a url, request it and use it as key source 550 if key.startswith("http"): 551 try: 552 resp, info = fetch_url(module, key) 553 if info['status'] != 200: 554 module.fail_json(msg=error_msg % key) 555 else: 556 key = resp.read() 557 except Exception: 558 module.fail_json(msg=error_msg % key) 559 560 # resp.read gives bytes on python3, convert to native string type 561 key = to_native(key, errors='surrogate_or_strict') 562 563 # extract individual keys into an array, skipping blank lines and comments 564 new_keys = [s for s in key.splitlines() if s and not s.startswith('#')] 565 566 # check current state -- just get the filename, don't create file 567 do_write = False 568 params["keyfile"] = keyfile(module, user, do_write, path, manage_dir) 569 existing_content = readfile(params["keyfile"]) 570 existing_keys = parsekeys(module, existing_content) 571 572 # Add a place holder for keys that should exist in the state=present and 573 # exclusive=true case 574 keys_to_exist = [] 575 576 # we will order any non exclusive new keys higher than all the existing keys, 577 # resulting in the new keys being written to the key file after existing keys, but 578 # in the order of new_keys 579 max_rank_of_existing_keys = len(existing_keys) 580 581 # Check our new keys, if any of them exist we'll continue. 582 for rank_index, new_key in enumerate(new_keys): 583 parsed_new_key = parsekey(module, new_key, rank=rank_index) 584 585 if not parsed_new_key: 586 module.fail_json(msg="invalid key specified: %s" % new_key) 587 588 if key_options is not None: 589 parsed_options = parseoptions(module, key_options) 590 # rank here is the rank in the provided new keys, which may be unrelated to rank in existing_keys 591 parsed_new_key = (parsed_new_key[0], parsed_new_key[1], parsed_options, parsed_new_key[3], parsed_new_key[4]) 592 593 if comment is not None: 594 parsed_new_key = (parsed_new_key[0], parsed_new_key[1], parsed_new_key[2], comment, parsed_new_key[4]) 595 596 matched = False 597 non_matching_keys = [] 598 599 if parsed_new_key[0] in existing_keys: 600 # Then we check if everything (except the rank at index 4) matches, including 601 # the key type and options. If not, we append this 602 # existing key to the non-matching list 603 # We only want it to match everything when the state 604 # is present 605 if parsed_new_key[:4] != existing_keys[parsed_new_key[0]][:4] and state == "present": 606 non_matching_keys.append(existing_keys[parsed_new_key[0]]) 607 else: 608 matched = True 609 610 # handle idempotent state=present 611 if state == "present": 612 keys_to_exist.append(parsed_new_key[0]) 613 if len(non_matching_keys) > 0: 614 for non_matching_key in non_matching_keys: 615 if non_matching_key[0] in existing_keys: 616 del existing_keys[non_matching_key[0]] 617 do_write = True 618 619 # new key that didn't exist before. Where should it go in the ordering? 620 if not matched: 621 # We want the new key to be after existing keys if not exclusive (rank > max_rank_of_existing_keys) 622 total_rank = max_rank_of_existing_keys + parsed_new_key[4] 623 # replace existing key tuple with new parsed key with its total rank 624 existing_keys[parsed_new_key[0]] = (parsed_new_key[0], parsed_new_key[1], parsed_new_key[2], parsed_new_key[3], total_rank) 625 do_write = True 626 627 elif state == "absent": 628 if not matched: 629 continue 630 del existing_keys[parsed_new_key[0]] 631 do_write = True 632 633 # remove all other keys to honor exclusive 634 # for 'exclusive', make sure keys are written in the order the new keys were 635 if state == "present" and exclusive: 636 to_remove = frozenset(existing_keys).difference(keys_to_exist) 637 for key in to_remove: 638 del existing_keys[key] 639 do_write = True 640 641 if do_write: 642 filename = keyfile(module, user, do_write, path, manage_dir, follow) 643 new_content = serialize(existing_keys) 644 645 diff = None 646 if module._diff: 647 diff = { 648 'before_header': params['keyfile'], 649 'after_header': filename, 650 'before': existing_content, 651 'after': new_content, 652 } 653 params['diff'] = diff 654 655 if not module.check_mode: 656 writefile(module, filename, new_content) 657 params['changed'] = True 658 659 return params 660 661 662def main(): 663 module = AnsibleModule( 664 argument_spec=dict( 665 user=dict(type='str', required=True), 666 key=dict(type='str', required=True, no_log=False), 667 path=dict(type='path'), 668 manage_dir=dict(type='bool', default=True), 669 state=dict(type='str', default='present', choices=['absent', 'present']), 670 key_options=dict(type='str', no_log=False), 671 exclusive=dict(type='bool', default=False), 672 comment=dict(type='str'), 673 validate_certs=dict(type='bool', default=True), 674 follow=dict(type='bool', default=False), 675 ), 676 supports_check_mode=True, 677 ) 678 679 results = enforce_state(module, module.params) 680 module.exit_json(**results) 681 682 683if __name__ == '__main__': 684 main() 685