2012-11-27 17:18:46 +01:00
#!/usr/bin/python
2012-08-03 03:29:10 +02:00
# -*- coding: utf-8 -*-
2012-07-28 23:02:16 +02:00
"""
Ansible module to add authorized_keys for ssh logins.
2012-05-30 22:41:38 +02:00
(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/>.
"""
2012-09-28 21:55:49 +02:00
DOCUMENTATION = '''
---
module: authorized_key
2012-10-01 09:18:54 +02:00
short_description: Adds or removes an SSH authorized key
2012-09-28 21:55:49 +02:00
description:
2012-10-01 09:18:54 +02:00
- Adds or removes an SSH authorized key for a user from a remote host.
2013-11-28 03:23:03 +01:00
version_added: "0.5"
2012-09-28 21:55:49 +02:00
options:
user:
description:
2013-06-07 23:43:42 +02:00
- The username on the remote host whose authorized_keys file will be modified
2012-09-28 21:55:49 +02:00
required: true
default: null
aliases: []
key:
description:
2013-06-07 23:43:42 +02:00
- The SSH public key, as a string
2012-09-28 21:55:49 +02:00
required: true
default: null
2013-03-26 16:12:09 +01:00
path:
description:
- Alternate path to the authorized_keys file
required: false
2013-04-19 04:43:14 +02:00
default: "(homedir)+/.ssh/authorized_keys"
2013-04-05 20:51:08 +02:00
version_added: "1.2"
2013-03-26 16:12:09 +01:00
manage_dir:
description:
2014-01-29 23:38:00 +01:00
- 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.
2013-03-26 16:12:09 +01:00
required: false
choices: [ "yes", "no" ]
default: "yes"
2013-04-05 20:51:08 +02:00
version_added: "1.2"
2012-09-28 21:55:49 +02:00
state:
description:
2013-08-08 22:59:28 +02:00
- Whether the given key (with the given key_options) should or should not be in the file
2012-09-28 21:55:49 +02:00
required: false
choices: [ "present", "absent" ]
default: "present"
2013-08-08 22:59:28 +02:00
key_options:
description:
- A string of ssh key options to be prepended to the key in the authorized_keys file
required: false
default: null
2013-10-11 21:14:07 +02:00
version_added: "1.4"
2013-05-30 22:10:16 +02:00
description:
2013-06-07 23:43:42 +02:00
- "Adds or removes authorized keys for particular user accounts"
2012-09-28 21:55:49 +02:00
author: Brad Olson
'''
2013-04-05 21:33:21 +02:00
EXAMPLES = '''
# Example using key data from a local file on the management machine
2013-06-14 11:53:43 +02:00
- authorized_key: user=charlie key="{{ lookup('file', '/home/charlie/.ssh/id_rsa.pub') }}"
2013-04-05 21:33:21 +02:00
# Using alternate directory locations:
2013-06-14 11:53:43 +02:00
- authorized_key: user=charlie
key="{{ lookup('file', '/home/charlie/.ssh/id_rsa.pub') }}"
path='/etc/ssh/authorized_keys/charlie'
manage_dir=no
2013-07-10 22:09:03 +02:00
# 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
2013-08-08 22:59:28 +02:00
# 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"'
2013-04-05 21:33:21 +02:00
'''
2012-07-28 23:02:16 +02:00
# 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
2013-03-26 16:12:09 +01:00
# 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)
2012-07-28 23:02:16 +02:00
# state = absent|present (default: present)
#
# see example in examples/playbooks
import sys
import os
import pwd
import os.path
2012-10-20 07:54:08 +02:00
import tempfile
2013-11-30 09:03:35 +01:00
import re
2013-10-11 21:14:07 +02:00
import shlex
2012-05-30 22:41:38 +02:00
2014-01-15 23:10:10 +01:00
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)
2013-03-26 16:12:09 +01:00
def keyfile(module, user, write=False, path=None, manage_dir=True):
2012-07-28 23:02:16 +02:00
"""
2012-08-07 02:07:02 +02:00
Calculate name of authorized keys file, optionally creating the
2012-05-30 22:41:38 +02:00
directories and file, properly setting permissions.
:param str user: name of user in passwd file
2012-07-02 21:16:57 +02:00
:param bool write: if True, write changes to authorized_keys file (creating directories if needed)
2013-03-26 16:12:09 +01:00
: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
2012-05-30 22:41:38 +02:00
:return: full path string to authorized_keys for user
"""
2012-11-08 01:43:09 +01:00
try:
user_entry = pwd.getpwnam(user)
except KeyError, e:
module.fail_json(msg="Failed to lookup user %s: %s" % (user, str(e)))
2013-03-26 16:12:09 +01:00
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
2012-07-28 23:02:16 +02:00
2012-08-07 02:07:02 +02:00
if not write:
2012-07-28 23:02:16 +02:00
return keysfile
2012-05-30 22:41:38 +02:00
uid = user_entry.pw_uid
gid = user_entry.pw_gid
2012-07-28 23:02:16 +02:00
2014-03-28 16:41:35 +01:00
if manage_dir:
2013-03-26 16:12:09 +01:00
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)
2012-07-28 23:02:16 +02:00
2013-04-12 20:46:41 +02:00
if not os.path.exists(keysfile):
basedir = os.path.dirname(keysfile)
if not os.path.exists(basedir):
os.makedirs(basedir)
2012-07-02 19:57:38 +02:00
try:
2012-07-02 21:16:57 +02:00
f = open(keysfile, "w") #touches file so we can set ownership and perms
2012-07-02 19:57:38 +02:00
finally:
f.close()
2012-12-14 01:29:40 +01:00
if module.selinux_enabled():
module.set_default_selinux_context(keysfile, False)
2012-07-28 23:02:16 +02:00
2013-05-08 01:20:38 +02:00
try:
os.chown(keysfile, uid, gid)
os.chmod(keysfile, 0600)
except OSError:
pass
2012-05-30 22:41:38 +02:00
return keysfile
2013-11-30 09:03:35 +01:00
def parseoptions(module, options):
2013-10-11 21:14:07 +02:00
'''
reads a string containing ssh-key options
and returns a dictionary of those options
'''
2014-01-15 23:10:10 +01:00
options_dict = keydict() #ordered dict
2013-10-11 21:14:07 +02:00
if options:
2013-11-30 09:03:35 +01:00
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)
2013-10-11 21:14:07 +02:00
else:
2013-11-30 09:03:35 +01:00
pos = match.end(0)
2013-10-11 21:14:07 +02:00
return options_dict
2013-11-30 09:03:35 +01:00
def parsekey(module, raw_key):
2013-10-11 21:14:07 +02:00
'''
parses a key, which may or may not contain a list
of ssh-key options at the beginning
'''
2013-10-25 03:12:56 +02:00
VALID_SSH2_KEY_TYPES = [
2014-02-28 18:46:54 +01:00
'ssh-ed25519',
2013-10-25 03:12:56 +02:00
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'ssh-dss',
'ssh-rsa',
]
2013-10-28 18:41:56 +01:00
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
2013-11-26 14:54:19 +01:00
# remove comment yaml escapes
raw_key = raw_key.replace('\#', '#')
2013-11-22 22:36:31 +01:00
# split key safely
lex = shlex.shlex(raw_key)
lex.quotes = ["'", '"']
2013-11-26 05:46:48 +01:00
lex.commenters = '' #keep comment hashes
2013-11-22 22:36:31 +01:00
lex.whitespace_split = True
key_parts = list(lex)
2013-10-28 18:41:56 +01:00
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:
2013-10-11 21:14:07 +02:00
return None
2014-01-15 23:10:10 +01:00
elif type_index > 0:
options = " ".join(key_parts[:type_index])
2013-10-28 18:41:56 +01:00
# parse the options (if any)
2013-11-30 09:03:35 +01:00
options = parseoptions(module, options)
2013-10-28 18:41:56 +01:00
# 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)
2013-10-11 21:14:07 +02:00
2013-11-30 09:03:35 +01:00
def readkeys(module, filename):
2012-07-28 23:02:16 +02:00
2012-08-07 02:07:02 +02:00
if not os.path.isfile(filename):
2013-10-28 18:41:56 +01:00
return {}
2013-10-11 21:14:07 +02:00
2013-10-14 16:59:30 +02:00
keys = {}
2012-07-28 23:02:16 +02:00
f = open(filename)
2013-10-11 21:14:07 +02:00
for line in f.readlines():
2013-11-30 09:03:35 +01:00
key_data = parsekey(module, line)
2013-10-11 21:14:07 +02:00
if key_data:
2013-10-14 16:59:30 +02:00
# use key as identifier
keys[key_data[0]] = key_data
2013-10-11 21:14:07 +02:00
else:
# for an invalid line, just append the line
# to the array so it will be re-output later
2013-10-14 16:59:30 +02:00
keys[line] = line
2012-07-28 23:02:16 +02:00
f.close()
2012-05-30 22:41:38 +02:00
return keys
2012-10-20 07:54:08 +02:00
def writekeys(module, filename, keys):
2012-07-28 23:02:16 +02:00
2012-10-25 05:50:11 +02:00
fd, tmp_path = tempfile.mkstemp('', 'tmp', os.path.dirname(filename))
2012-10-20 07:54:08 +02:00
f = open(tmp_path,"w")
try:
2013-10-14 16:59:30 +02:00
for index, key in keys.items():
2013-10-11 21:14:07 +02:00
try:
(keyhash,type,options,comment) = key
option_str = ""
if options:
2013-10-15 10:11:05 +02:00
option_strings = []
2014-01-15 23:10:10 +01:00
for option_key in options.keys():
2013-10-11 21:14:07 +02:00
if options[option_key]:
2013-10-13 13:48:19 +02:00
option_strings.append("%s=\"%s\"" % (option_key, options[option_key]))
2013-10-11 21:14:07 +02:00
else:
2013-10-28 18:41:56 +01:00
option_strings.append("%s" % option_key)
2013-10-15 10:11:05 +02:00
option_str = ",".join(option_strings)
option_str += " "
2013-10-11 21:14:07 +02:00
key_line = "%s%s %s %s\n" % (option_str, type, keyhash, comment)
except:
key_line = key
f.writelines(key_line)
2012-10-20 07:54:08 +02:00
except IOError, e:
module.fail_json(msg="Failed to write to file %s: %s" % (tmp_path, str(e)))
2012-07-28 23:02:16 +02:00
f.close()
2013-04-24 03:52:23 +02:00
module.atomic_move(tmp_path, filename)
2012-07-28 23:02:16 +02:00
def enforce_state(module, params):
"""
Add or remove key.
2012-05-30 22:41:38 +02:00
"""
2013-10-11 21:14:07 +02:00
user = params["user"]
key = params["key"]
path = params.get("path", None)
manage_dir = params.get("manage_dir", True)
state = params.get("state", "present")
2013-08-08 22:59:28 +02:00
key_options = params.get("key_options", None)
2012-05-30 22:41:38 +02:00
2014-01-16 19:14:37 +01:00
# extract indivial keys into an array, skipping blank lines and comments
key = [s for s in key.splitlines() if s and not s.startswith('#')]
2013-01-13 03:38:53 +01:00
2012-07-28 23:02:16 +02:00
# check current state -- just get the filename, don't create file
2013-10-11 21:14:07 +02:00
do_write = False
params["keyfile"] = keyfile(module, user, do_write, path, manage_dir)
2013-11-30 09:03:35 +01:00
existing_keys = readkeys(module, params["keyfile"])
2013-02-03 01:07:10 +01:00
# Check our new keys, if any of them exist we'll continue.
2013-02-03 01:17:18 +01:00
for new_key in key:
2014-01-15 23:10:10 +01:00
parsed_new_key = parsekey(module, new_key)
2013-08-08 22:59:28 +02:00
if key_options is not None:
2014-01-15 23:10:10 +01:00
parsed_options = parseoptions(module, key_options)
parsed_new_key = (parsed_new_key[0], parsed_new_key[1], parsed_options, parsed_new_key[3])
2013-10-11 21:14:07 +02:00
if not parsed_new_key:
module.fail_json(msg="invalid key specified: %s" % new_key)
present = False
matched = False
non_matching_keys = []
2013-10-14 16:59:30 +02:00
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
2013-08-08 22:59:28 +02:00
2013-02-03 01:07:10 +01:00
# handle idempotent state=present
if state=="present":
2013-10-28 18:41:56 +01:00
if len(non_matching_keys) > 0:
2013-10-11 21:14:07 +02:00
for non_matching_key in non_matching_keys:
2013-10-28 18:41:56 +01:00
if non_matching_key[0] in existing_keys:
del existing_keys[non_matching_key[0]]
do_write = True
2013-10-11 21:14:07 +02:00
if not matched:
2013-10-14 16:59:30 +02:00
existing_keys[parsed_new_key[0]] = parsed_new_key
2013-10-11 21:14:07 +02:00
do_write = True
2013-02-03 01:07:10 +01:00
elif state=="absent":
2013-10-11 21:14:07 +02:00
if not matched:
2013-02-03 01:07:10 +01:00
continue
2013-10-14 16:59:30 +02:00
del existing_keys[parsed_new_key[0]]
2013-10-11 21:14:07 +02:00
do_write = True
if do_write:
writekeys(module, keyfile(module, user, do_write, path, manage_dir), existing_keys)
params['changed'] = True
2013-02-03 01:07:10 +01:00
2012-07-28 23:02:16 +02:00
return params
def main():
module = AnsibleModule(
argument_spec = dict(
2013-10-11 21:14:07 +02:00
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'),
2012-07-28 23:02:16 +02:00
)
)
results = enforce_state(module, module.params)
module.exit_json(**results)
2013-12-02 21:13:49 +01:00
# import module snippets
2013-12-02 21:11:23 +01:00
from ansible.module_utils.basic import *
2012-08-07 02:07:02 +02:00
main()