mirror of
				https://github.com/ansible-collections/community.general.git
				synced 2024-09-14 20:13:21 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			423 lines
		
	
	
	
		
			13 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			423 lines
		
	
	
	
		
			13 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| #!/usr/bin/python
 | |
| # -*- coding: utf-8 -*-
 | |
| 
 | |
| """
 | |
| Ansible module to add authorized_keys for ssh logins.
 | |
| (c) 2012, Brad Olson <brado@movedbylight.com>
 | |
| 
 | |
| This file is part of Ansible
 | |
| 
 | |
| Ansible is free software: you can redistribute it and/or modify
 | |
| it under the terms of the GNU General Public License as published by
 | |
| the Free Software Foundation, either version 3 of the License, or
 | |
| (at your option) any later version.
 | |
| 
 | |
| Ansible is distributed in the hope that it will be useful,
 | |
| but WITHOUT ANY WARRANTY; without even the implied warranty of
 | |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | |
| GNU General Public License for more details.
 | |
| 
 | |
| You should have received a copy of the GNU General Public License
 | |
| along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
 | |
| """
 | |
| 
 | |
| DOCUMENTATION = '''
 | |
| ---
 | |
| module: authorized_key
 | |
| short_description: Adds or removes an SSH authorized key
 | |
| description:
 | |
|      - Adds or removes an SSH authorized key for a user from a remote host.
 | |
| version_added: "0.5"
 | |
| options:
 | |
|   user:
 | |
|     description:
 | |
|       - The username on the remote host whose authorized_keys file will be modified
 | |
|     required: true
 | |
|     default: null
 | |
|     aliases: []
 | |
|   key:
 | |
|     description:
 | |
|       - The SSH public key, as a string
 | |
|     required: true
 | |
|     default: null
 | |
|   path:
 | |
|     description:
 | |
|       - Alternate path to the authorized_keys file
 | |
|     required: false
 | |
|     default: "(homedir)+/.ssh/authorized_keys"
 | |
|     version_added: "1.2"
 | |
|   manage_dir:
 | |
|     description:
 | |
|       - Whether this module should manage the directory of the authorized_keys file
 | |
|     required: false
 | |
|     choices: [ "yes", "no" ]
 | |
|     default: "yes"
 | |
|     version_added: "1.2"
 | |
|   state:
 | |
|     description:
 | |
|       - Whether the given key (with the given key_options) should or should not be in the file
 | |
|     required: false
 | |
|     choices: [ "present", "absent" ]
 | |
|     default: "present"
 | |
|   key_options:
 | |
|     description:
 | |
|       - A string of ssh key options to be prepended to the key in the authorized_keys file
 | |
|     required: false
 | |
|     default: null
 | |
|     version_added: "1.4"
 | |
| description:
 | |
|     - "Adds or removes authorized keys for particular user accounts"
 | |
| author: Brad Olson
 | |
| '''
 | |
| 
 | |
| EXAMPLES = '''
 | |
| # Example using key data from a local file on the management machine
 | |
| - authorized_key: user=charlie key="{{ lookup('file', '/home/charlie/.ssh/id_rsa.pub') }}"
 | |
| 
 | |
| # Using alternate directory locations:
 | |
| - authorized_key: user=charlie
 | |
|                   key="{{ lookup('file', '/home/charlie/.ssh/id_rsa.pub') }}"
 | |
|                   path='/etc/ssh/authorized_keys/charlie'
 | |
|                   manage_dir=no
 | |
| 
 | |
| # Using with_file
 | |
| - name: Set up authorized_keys for the deploy user
 | |
|   authorized_key: user=deploy
 | |
|                   key="{{ item }}"
 | |
|   with_file:
 | |
|     - public_keys/doe-jane
 | |
|     - public_keys/doe-john
 | |
| 
 | |
| # Using key_options:
 | |
| - authorized_key: user=charlie
 | |
|                   key="{{ lookup('file', '/home/charlie/.ssh/id_rsa.pub') }}"
 | |
|                   key_options='no-port-forwarding,host="10.0.1.1"'
 | |
| '''
 | |
| 
 | |
| # Makes sure the public key line is present or absent in the user's .ssh/authorized_keys.
 | |
| #
 | |
| # Arguments
 | |
| # =========
 | |
| #    user = username
 | |
| #    key = line to add to authorized_keys for user
 | |
| #    path = path to the user's authorized_keys file (default: ~/.ssh/authorized_keys)
 | |
| #    manage_dir = whether to create, and control ownership of the directory (default: true)
 | |
| #    state = absent|present (default: present)
 | |
| #
 | |
| # see example in examples/playbooks
 | |
| 
 | |
| import sys
 | |
| import os
 | |
| import pwd
 | |
| import os.path
 | |
| import tempfile
 | |
| import re
 | |
| import shlex
 | |
| 
 | |
| class keydict(dict):
 | |
| 
 | |
|     """ a dictionary that maintains the order of keys as they are added """
 | |
|     
 | |
|     # http://stackoverflow.com/questions/2328235/pythonextend-the-dict-class
 | |
| 
 | |
|     def __init__(self, *args, **kw):
 | |
|         super(keydict,self).__init__(*args, **kw)
 | |
|         self.itemlist = super(keydict,self).keys()
 | |
|     def __setitem__(self, key, value):
 | |
|         self.itemlist.append(key)
 | |
|         super(keydict,self).__setitem__(key, value)        
 | |
|     def __iter__(self):
 | |
|         return iter(self.itemlist)
 | |
|     def keys(self):
 | |
|         return self.itemlist
 | |
|     def values(self):
 | |
|         return [self[key] for key in self]
 | |
|     def itervalues(self):
 | |
|         return (self[key] for key in self)    
 | |
| 
 | |
| def keyfile(module, user, write=False, path=None, manage_dir=True):
 | |
|     """
 | |
|     Calculate name of authorized keys file, optionally creating the
 | |
|     directories and file, properly setting permissions.
 | |
| 
 | |
|     :param str user: name of user in passwd file
 | |
|     :param bool write: if True, write changes to authorized_keys file (creating directories if needed)
 | |
|     :param str path: if not None, use provided path rather than default of '~user/.ssh/authorized_keys'
 | |
|     :param bool manage_dir: if True, create and set ownership of the parent dir of the authorized_keys file
 | |
|     :return: full path string to authorized_keys for user
 | |
|     """
 | |
| 
 | |
|     try:
 | |
|         user_entry = pwd.getpwnam(user)
 | |
|     except KeyError, e:
 | |
|         module.fail_json(msg="Failed to lookup user %s: %s" % (user, str(e)))
 | |
|     if path is None:
 | |
|         homedir    = user_entry.pw_dir
 | |
|         sshdir     = os.path.join(homedir, ".ssh")
 | |
|         keysfile   = os.path.join(sshdir, "authorized_keys")
 | |
|     else:
 | |
|         sshdir     = os.path.dirname(path)
 | |
|         keysfile   = path
 | |
| 
 | |
|     if not write:
 | |
|         return keysfile
 | |
| 
 | |
|     uid = user_entry.pw_uid
 | |
|     gid = user_entry.pw_gid
 | |
| 
 | |
|     if manage_dir in BOOLEANS_TRUE:
 | |
|         if not os.path.exists(sshdir):
 | |
|             os.mkdir(sshdir, 0700)
 | |
|             if module.selinux_enabled():
 | |
|                 module.set_default_selinux_context(sshdir, False)
 | |
|         os.chown(sshdir, uid, gid)
 | |
|         os.chmod(sshdir, 0700)
 | |
| 
 | |
|     if not os.path.exists(keysfile):
 | |
|         basedir = os.path.dirname(keysfile)
 | |
|         if not os.path.exists(basedir):
 | |
|             os.makedirs(basedir)
 | |
|         try:
 | |
|             f = open(keysfile, "w") #touches file so we can set ownership and perms
 | |
|         finally:
 | |
|             f.close()
 | |
|         if module.selinux_enabled():
 | |
|             module.set_default_selinux_context(keysfile, False)
 | |
| 
 | |
|     try:
 | |
|         os.chown(keysfile, uid, gid)
 | |
|         os.chmod(keysfile, 0600)
 | |
|     except OSError:
 | |
|         pass
 | |
| 
 | |
|     return keysfile
 | |
| 
 | |
| def parseoptions(module, options):
 | |
|     ''' 
 | |
|     reads a string containing ssh-key options 
 | |
|     and returns a dictionary of those options
 | |
|     '''
 | |
|     options_dict = keydict() #ordered dict
 | |
|     if options:
 | |
|         token_exp = [
 | |
|             # matches separator
 | |
|             (r',+', False),
 | |
|             # matches option with value, e.g. from="x,y"
 | |
|             (r'([a-z0-9-]+)="((?:[^"\\]|\\.)*)"', True),
 | |
|             # matches single option, e.g. no-agent-forwarding 
 | |
|             (r'[a-z0-9-]+', True)
 | |
|         ]
 | |
| 
 | |
|         pos = 0
 | |
|         while pos < len(options):
 | |
|             match = None
 | |
|             for pattern, is_valid_option in token_exp:
 | |
|                 regex = re.compile(pattern, re.IGNORECASE)
 | |
|                 match = regex.match(options, pos)
 | |
|                 if match:
 | |
|                     text = match.group(0)
 | |
|                     if is_valid_option:
 | |
|                         if len(match.groups()) == 2:
 | |
|                             options_dict[match.group(1)] = match.group(2)
 | |
|                         else:
 | |
|                             options_dict[text] = None
 | |
|                     break
 | |
|             if not match:
 | |
|                 module.fail_json(msg="invalid option string: %s" % options)
 | |
|             else:
 | |
|                 pos = match.end(0)
 | |
| 
 | |
|     return options_dict
 | |
| 
 | |
| def parsekey(module, raw_key):
 | |
|     '''
 | |
|     parses a key, which may or may not contain a list
 | |
|     of ssh-key options at the beginning
 | |
|     '''
 | |
| 
 | |
|     VALID_SSH2_KEY_TYPES = [
 | |
|         'ecdsa-sha2-nistp256',
 | |
|         'ecdsa-sha2-nistp384',
 | |
|         'ecdsa-sha2-nistp521', 
 | |
|         'ssh-dss',
 | |
|         'ssh-rsa',
 | |
|     ]
 | |
| 
 | |
|     options    = None   # connection options
 | |
|     key        = None   # encrypted key string
 | |
|     key_type   = None   # type of ssh key
 | |
|     type_index = None   # index of keytype in key string|list
 | |
| 
 | |
|     # remove comment yaml escapes
 | |
|     raw_key = raw_key.replace('\#', '#')
 | |
| 
 | |
|     # split key safely
 | |
|     lex = shlex.shlex(raw_key)
 | |
|     lex.quotes = ["'", '"']
 | |
|     lex.commenters = '' #keep comment hashes
 | |
|     lex.whitespace_split = True
 | |
|     key_parts = list(lex)
 | |
| 
 | |
|     for i in range(0, len(key_parts)):
 | |
|         if key_parts[i] in VALID_SSH2_KEY_TYPES:
 | |
|             type_index = i
 | |
|             key_type = key_parts[i]
 | |
|             break
 | |
| 
 | |
|     # check for options
 | |
|     if type_index is None:
 | |
|         return None
 | |
|     elif type_index > 0:
 | |
|         options = " ".join(key_parts[:type_index])
 | |
| 
 | |
|     # parse the options (if any)
 | |
|     options = parseoptions(module, options)
 | |
| 
 | |
|     # get key after the type index
 | |
|     key = key_parts[(type_index + 1)]
 | |
| 
 | |
|     # set comment to everything after the key
 | |
|     if len(key_parts) > (type_index + 1):
 | |
|         comment = " ".join(key_parts[(type_index + 2):])
 | |
| 
 | |
|     return (key, key_type, options, comment)
 | |
| 
 | |
| def readkeys(module, filename):
 | |
| 
 | |
|     if not os.path.isfile(filename):
 | |
|         return {}
 | |
| 
 | |
|     keys = {}
 | |
|     f = open(filename)
 | |
|     for line in f.readlines():
 | |
|         key_data = parsekey(module, line)
 | |
|         if key_data:
 | |
|             # use key as identifier
 | |
|             keys[key_data[0]] = key_data
 | |
|         else:
 | |
|             # for an invalid line, just append the line
 | |
|             # to the array so it will be re-output later
 | |
|             keys[line] = line
 | |
|     f.close()
 | |
|     return keys
 | |
| 
 | |
| def writekeys(module, filename, keys):
 | |
| 
 | |
|     fd, tmp_path = tempfile.mkstemp('', 'tmp', os.path.dirname(filename))
 | |
|     f = open(tmp_path,"w")
 | |
|     try:
 | |
|         for index, key in keys.items():
 | |
|             try:
 | |
|                 (keyhash,type,options,comment) = key
 | |
|                 option_str = ""
 | |
|                 if options:
 | |
|                     option_strings = []
 | |
|                     for option_key in options.keys():
 | |
|                         if options[option_key]:
 | |
|                             option_strings.append("%s=\"%s\"" % (option_key, options[option_key]))
 | |
|                         else:
 | |
|                             option_strings.append("%s" % option_key)
 | |
| 
 | |
|                     option_str = ",".join(option_strings)
 | |
|                     option_str += " "
 | |
|                 key_line = "%s%s %s %s\n" % (option_str, type, keyhash, comment)
 | |
|             except:
 | |
|                 key_line = key
 | |
|             f.writelines(key_line)
 | |
|     except IOError, e:
 | |
|         module.fail_json(msg="Failed to write to file %s: %s" % (tmp_path, str(e)))
 | |
|     f.close()
 | |
|     module.atomic_move(tmp_path, filename)
 | |
| 
 | |
| def enforce_state(module, params):
 | |
|     """
 | |
|     Add or remove key.
 | |
|     """
 | |
| 
 | |
|     user        = params["user"]
 | |
|     key         = params["key"]
 | |
|     path        = params.get("path", None)
 | |
|     manage_dir  = params.get("manage_dir", True)
 | |
|     state       = params.get("state", "present")
 | |
|     key_options = params.get("key_options", None)
 | |
| 
 | |
|     # extract indivial keys into an array, skipping blank lines and comments
 | |
|     key = [s for s in key.splitlines() if s and not s.startswith('#')]
 | |
| 
 | |
| 
 | |
|     # check current state -- just get the filename, don't create file
 | |
|     do_write = False
 | |
|     params["keyfile"] = keyfile(module, user, do_write, path, manage_dir)
 | |
|     existing_keys = readkeys(module, params["keyfile"])
 | |
| 
 | |
|     # Check our new keys, if any of them exist we'll continue.
 | |
|     for new_key in key:
 | |
|         parsed_new_key = parsekey(module, new_key)
 | |
|         if key_options is not None:
 | |
|             parsed_options = parseoptions(module, key_options)
 | |
|             parsed_new_key = (parsed_new_key[0], parsed_new_key[1], parsed_options, parsed_new_key[3])
 | |
| 
 | |
|         if not parsed_new_key:
 | |
|             module.fail_json(msg="invalid key specified: %s" % new_key)
 | |
| 
 | |
|         present = False
 | |
|         matched = False
 | |
|         non_matching_keys = []
 | |
| 
 | |
|         if parsed_new_key[0] in existing_keys:
 | |
|             present = True
 | |
|             # Then we check if everything matches, including
 | |
|             # the key type and options. If not, we append this
 | |
|             # existing key to the non-matching list
 | |
|             # We only want it to match everything when the state
 | |
|             # is present
 | |
|             if parsed_new_key != existing_keys[parsed_new_key[0]] and state == "present":
 | |
|                 non_matching_keys.append(existing_keys[parsed_new_key[0]])
 | |
|             else:
 | |
|                 matched = True
 | |
| 
 | |
| 
 | |
|         # handle idempotent state=present
 | |
|         if state=="present":
 | |
|             if len(non_matching_keys) > 0:
 | |
|                 for non_matching_key in non_matching_keys:
 | |
|                     if non_matching_key[0] in existing_keys:
 | |
|                         del existing_keys[non_matching_key[0]]
 | |
|                         do_write = True
 | |
| 
 | |
|             if not matched:
 | |
|                 existing_keys[parsed_new_key[0]] = parsed_new_key
 | |
|                 do_write = True
 | |
| 
 | |
|         elif state=="absent":
 | |
|             if not matched:
 | |
|                 continue
 | |
|             del existing_keys[parsed_new_key[0]]
 | |
|             do_write = True
 | |
| 
 | |
|     if do_write:
 | |
|         writekeys(module, keyfile(module, user, do_write, path, manage_dir), existing_keys)
 | |
|         params['changed'] = True
 | |
| 
 | |
|     return params
 | |
| 
 | |
| def main():
 | |
| 
 | |
|     module = AnsibleModule(
 | |
|         argument_spec = dict(
 | |
|            user        = dict(required=True, type='str'),
 | |
|            key         = dict(required=True, type='str'),
 | |
|            path        = dict(required=False, type='str'),
 | |
|            manage_dir  = dict(required=False, type='bool', default=True),
 | |
|            state       = dict(default='present', choices=['absent','present']),
 | |
|            key_options = dict(required=False, type='str'),
 | |
|            unique      = dict(default=False, type='bool'),
 | |
|         )
 | |
|     )
 | |
| 
 | |
|     results = enforce_state(module, module.params)
 | |
|     module.exit_json(**results)
 | |
| 
 | |
| # import module snippets
 | |
| from ansible.module_utils.basic import *
 | |
| main()
 |