Skip to content

Commit

Permalink
Add comment option to authorized_key (ansible#27781)
Browse files Browse the repository at this point in the history
* Add comment option to authorized_keys

* Update version_added for authorized_keys comment

* PEP8

* Include index rank in parsed_key_key

*  Properly display diff

Only display diff if specificed via settings

* Fix PEP8 test failure

Removed from legacy files since it is now properly formatted

* Cleanup integration test formatting and add test for new comment feature

* Correct version_added for new option
  • Loading branch information
samdoran authored Aug 15, 2017
1 parent 8a69706 commit 2711271
Show file tree
Hide file tree
Showing 4 changed files with 234 additions and 152 deletions.
109 changes: 68 additions & 41 deletions lib/ansible/modules/system/authorized_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@
default: "yes"
choices: ["yes", "no"]
version_added: "2.1"
comment:
description:
- Change the comment on the public key. Rewriting the comment is useful in
cases such as fetching it from GitHub or GitLab.
- If no comment is specified, the existing comment will be kept.
required: false
default: None
version_added: "2.4"
author: "Ansible Core Team"
'''

Expand Down Expand Up @@ -220,6 +228,7 @@
from ansible.module_utils.pycompat24 import get_exception
from ansible.module_utils.urls import fetch_url


class keydict(dict):

""" a dictionary that maintains the order of keys as they are added
Expand Down Expand Up @@ -247,8 +256,8 @@ class keydict(dict):
# http://stackoverflow.com/questions/2328235/pythonextend-the-dict-class

def __init__(self, *args, **kw):
super(keydict,self).__init__(*args, **kw)
self.itemlist = list(super(keydict,self).keys())
super(keydict, self).__init__(*args, **kw)
self.itemlist = list(super(keydict, self).keys())

def __setitem__(self, key, value):
self.itemlist.append(key)
Expand Down Expand Up @@ -309,12 +318,12 @@ def keyfile(module, user, write=False, path=None, manage_dir=True):
module.fail_json(msg="Either user must exist or you must provide full path to key file in check mode")
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")
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
sshdir = os.path.dirname(path)
keysfile = path

if not write:
return keysfile
Expand All @@ -335,7 +344,7 @@ def keyfile(module, user, write=False, path=None, manage_dir=True):
if not os.path.exists(basedir):
os.makedirs(basedir)
try:
f = open(keysfile, "w") #touches file so we can set ownership and perms
f = open(keysfile, "w") # touches file so we can set ownership and perms
finally:
f.close()
if module.selinux_enabled():
Expand All @@ -349,12 +358,13 @@ def keyfile(module, user, write=False, path=None, manage_dir=True):

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
options_dict = keydict() # ordered dict
if options:
# the following regex will split on commas while
# ignoring those commas that fall within quotes
Expand All @@ -369,6 +379,7 @@ def parseoptions(module, options):

return options_dict


def parsekey(module, raw_key, rank=None):
'''
parses a key, which may or may not contain a list
Expand All @@ -387,9 +398,9 @@ def parsekey(module, raw_key, rank=None):
'ssh-rsa',
]

options = None # connection options
key = None # encrypted key string
key_type = None # type of ssh key
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
Expand All @@ -398,7 +409,7 @@ def parsekey(module, raw_key, rank=None):
# split key safely
lex = shlex.shlex(raw_key)
lex.quotes = []
lex.commenters = '' #keep comment hashes
lex.commenters = '' # keep comment hashes
lex.whitespace_split = True
key_parts = list(lex)

Expand Down Expand Up @@ -430,6 +441,7 @@ def parsekey(module, raw_key, rank=None):

return (key, key_type, options, comment, rank)


def readfile(filename):

if not os.path.isfile(filename):
Expand All @@ -441,6 +453,7 @@ def readfile(filename):
finally:
f.close()


def parsekeys(module, lines):
keys = {}
for rank_index, line in enumerate(lines.splitlines(True)):
Expand All @@ -454,10 +467,11 @@ def parsekeys(module, lines):
keys[line] = (line, 'skipped', None, None, rank_index)
return keys


def writefile(module, filename, content):

fd, tmp_path = tempfile.mkstemp('', 'tmp', os.path.dirname(filename))
f = open(tmp_path,"w")
f = open(tmp_path, "w")

try:
f.write(content)
Expand All @@ -467,6 +481,7 @@ def writefile(module, filename, content):
f.close()
module.atomic_move(tmp_path, filename)


def serialize(keys):
lines = []
new_keys = keys.values()
Expand Down Expand Up @@ -496,24 +511,26 @@ def serialize(keys):
key_line = key[0]
else:
key_line = "%s%s %s %s\n" % (option_str, key_type, keyhash, comment)
except:
except Exception:
key_line = key
lines.append(key_line)
return ''.join(lines)


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")
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)
exclusive = params.get("exclusive", False)
error_msg = "Error getting key from: %s"
exclusive = params.get("exclusive", False)
comment = params.get("comment", None)
error_msg = "Error getting key from: %s"

# if the key is a url, request it and use it as key source
if key.startswith("http"):
Expand Down Expand Up @@ -559,6 +576,9 @@ def enforce_state(module, params):
# rank here is the rank in the provided new keys, which may be unrelated to rank in existing_keys
parsed_new_key = (parsed_new_key[0], parsed_new_key[1], parsed_options, parsed_new_key[3], parsed_new_key[4])

if comment is not None:
parsed_new_key = (parsed_new_key[0], parsed_new_key[1], parsed_new_key[2], comment, parsed_new_key[4])

matched = False
non_matching_keys = []

Expand All @@ -574,7 +594,7 @@ def enforce_state(module, params):
matched = True

# handle idempotent state=present
if state=="present":
if state == "present":
keys_to_exist.append(parsed_new_key[0])
if len(non_matching_keys) > 0:
for non_matching_key in non_matching_keys:
Expand All @@ -590,7 +610,7 @@ def enforce_state(module, params):
existing_keys[parsed_new_key[0]] = (parsed_new_key[0], parsed_new_key[1], parsed_new_key[2], parsed_new_key[3], total_rank)
do_write = True

elif state=="absent":
elif state == "absent":
if not matched:
continue
del existing_keys[parsed_new_key[0]]
Expand All @@ -607,41 +627,48 @@ def enforce_state(module, params):
if do_write:
filename = keyfile(module, user, do_write, path, manage_dir)
new_content = serialize(existing_keys)
diff = {
'before_header': params['keyfile'],
'after_header': filename,
'before': existing_content,
'after': new_content,
}

diff = None
if module._diff:
diff = {
'before_header': params['keyfile'],
'after_header': filename,
'before': existing_content,
'after': new_content,
}
params['diff'] = diff

if module.check_mode:
module.exit_json(changed=True, diff=diff)
writefile(module, filename, new_content)
params['changed'] = True
params['diff'] = diff
else:
if module.check_mode:
module.exit_json(changed=False)

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'),
exclusive = dict(default=False, type='bool'),
validate_certs = dict(default=True, type='bool'),
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'),
exclusive=dict(default=False, type='bool'),
comment=dict(required=False, default=None, type='str'),
validate_certs=dict(default=True, type='bool'),
),
supports_check_mode=True
)

results = enforce_state(module, module.params)
module.exit_json(**results)


if __name__ == '__main__':
main()
30 changes: 13 additions & 17 deletions test/integration/targets/authorized_key/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -1,39 +1,35 @@
---
dss_key_basic: >
ssh-dss DATA_BASIC root@testing
dss_key_unquoted_option: >
idle-timeout=5m ssh-dss DATA_UNQUOTED_OPTION root@testing
dss_key_command: >
command="/bin/true" ssh-dss DATA_COMMAND root@testing
dss_key_complex_command: >
command="echo foo 'bar baz'" ssh-dss DATA_COMPLEX_COMMAND root@testing
dss_key_command_single_option: >
no-port-forwarding,command="/bin/true" ssh-dss DATA_COMMAND_SINGLE_OPTIONS root@testing
dss_key_command_multiple_options: >
no-port-forwarding,idle-timeout=5m,command="/bin/true" ssh-dss DATA_COMMAND_MULTIPLE_OPTIONS root@testing
dss_key_trailing: >
ssh-dss DATA_TRAILING root@testing foo bar baz
rsa_key_basic: >
ssh-rsa DATA_BASIC root@testing
dss_key_basic: ssh-dss DATA_BASIC root@testing
dss_key_unquoted_option: idle-timeout=5m ssh-dss DATA_UNQUOTED_OPTION root@testing
dss_key_command: command="/bin/true" ssh-dss DATA_COMMAND root@testing
dss_key_complex_command: command="echo foo 'bar baz'" ssh-dss DATA_COMPLEX_COMMAND root@testing
dss_key_command_single_option: no-port-forwarding,command="/bin/true" ssh-dss DATA_COMMAND_SINGLE_OPTIONS root@testing
dss_key_command_multiple_options: no-port-forwarding,idle-timeout=5m,command="/bin/true" ssh-dss DATA_COMMAND_MULTIPLE_OPTIONS root@testing
dss_key_trailing: ssh-dss DATA_TRAILING root@testing foo bar baz
rsa_key_basic: ssh-rsa DATA_BASIC root@testing

multiple_key_base: |
ssh-rsa DATA_BASIC 1@testing
ssh-dss DATA_TRAILING 2@testing foo bar baz
ssh-dss DATA_TRAILING 3@testing foo bar baz
ecdsa-sha2-nistp521 ECDSA_DATA 4@testing
multiple_key_different_order: |
ssh-dss DATA_TRAILING 2@testing foo bar baz
ssh-dss DATA_TRAILING 3@testing foo bar baz
ssh-rsa DATA_BASIC 1@testing
ecdsa-sha2-nistp521 ECDSA_DATA 4@testing
multiple_key_different_order_2: |
ssh-dss DATA_TRAILING 2@testing foo bar baz
ssh-rsa WHATEVER 2.5@testing
ssh-dss DATA_TRAILING 3@testing foo bar baz
ssh-rsa DATA_BASIC 1@testing
ecdsa-sha2-nistp521 ECDSA_DATA 4@testing
multiple_key_exclusive: |
ssh-rsa DATA_BASIC 1@testing
ecdsa-sha2-nistp521 ECDSA_DATA 4@testing
multiple_keys_comments: |
ssh-rsa DATA_BASIC 1@testing
# I like adding comments yo-dude-this-is-not-a-key INVALID_DATA 2@testing
Expand Down
Loading

0 comments on commit 2711271

Please sign in to comment.