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