684d46b170
Also adds an integration test for authorized_key for future validation. Fixes #6700
410 lines
13 KiB
Python
410 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. Make sure to set C(manage_dir=no) if you are using an alternate directory for authorized_keys set with C(path), since you could lock yourself out of SSH access. See the example below.
|
|
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:
|
|
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:
|
|
try:
|
|
# the following regex will split on commas while
|
|
# ignoring those commas that fall within quotes
|
|
regex = re.compile(r'''((?:[^,"']|"[^"]*"|'[^']*')+)''')
|
|
parts = regex.split(options)[1:-1]
|
|
for part in parts:
|
|
if "=" in part:
|
|
(key, value) = part.split("=", 1)
|
|
options_dict[key] = value
|
|
elif part != ",":
|
|
options_dict[part] = None
|
|
except:
|
|
module.fail_json(msg="invalid option string: %s" % options)
|
|
|
|
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 = [
|
|
'ssh-ed25519',
|
|
'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()
|