diff --git a/lib/ansible/modules/crypto/openssh_keypair.py b/lib/ansible/modules/crypto/openssh_keypair.py new file mode 100644 index 00000000000..a298ad47a67 --- /dev/null +++ b/lib/ansible/modules/crypto/openssh_keypair.py @@ -0,0 +1,315 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2018, David Kainz +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = ''' +--- +module: openssh_keypair +author: "David Kainz (@lolcube)" +version_added: "2.8" +short_description: Generate OpenSSH private and public keys. +description: + - "This module allows one to (re)generate OpenSSH private and public keys. It uses + ssh-keygen to generate keys. One can generate C(rsa), C(dsa), C(rsa1), C(ed25519) + or C(ecdsa) private keys." +requirements: + - "ssh-keygen" +options: + state: + required: false + default: present + choices: [ present, absent ] + description: + - Whether the private and public keys should exist or not, taking action if the state is different from what is stated. + size: + required: false + description: + - "Specifies the number of bits in the private key to create. For RSA keys, the minimum size is 1024 bits and the default is 4096 bits. + Generally, 2048 bits is considered sufficient. DSA keys must be exactly 1024 bits as specified by FIPS 186-2. + For ECDSA keys, size determines the key length by selecting from one of three elliptic curve sizes: 256, 384 or 521 bits. + Attempting to use bit lengths other than these three values for ECDSA keys will cause this module to fail. + Ed25519 keys have a fixed length and the size will be ignored." + type: + required: false + default: rsa + choices: ['rsa', 'dsa', 'rsa1', 'ecdsa', 'ed25519'] + description: + - "The algorithm used to generate the SSH private key. C(rsa1) is for protocol version 1. + C(rsa1) is deprecated and may not be supported by every version of ssh-keygen." + force: + required: false + default: false + type: bool + description: + - Should the key be regenerated even if it already exists + path: + required: true + description: + - Name of the files containing the public and private key. The file containing the public key will have the extension C(.pub). + comment: + required: false + description: + - Provides a new comment to the public key. When checking if the key is in the correct state this will be ignored. + +extends_documentation_fragment: files +''' + +EXAMPLES = ''' +# Generate an OpenSSH keypair with the default values (4096 bits, rsa) +- openssh_keypair: + path: /tmp/id_ssh_rsa + +# Generate an OpenSSH rsa keypair with a different size (2048 bits) +- openssh_keypair: + path: /tmp/id_ssh_rsa + size: 2048 + +# Force regenerate an OpenSSH keypair if it already exists +- openssh_keypair: + path: /tmp/id_ssh_rsa + force: True + +# Generate an OpenSSH keypair with a different algorithm (dsa) +- openssh_keypair: + path: /tmp/id_ssh_dsa + type: dsa +''' + +RETURN = ''' +size: + description: Size (in bits) of the SSH private key + returned: changed or success + type: int + sample: 4096 +type: + description: Algorithm used to generate the SSH private key + returned: changed or success + type: string + sample: rsa +filename: + description: Path to the generated SSH private key file + returned: changed or success + type: string + sample: /tmp/id_ssh_rsa +fingerprint: + description: The fingerprint of the key. + returned: changed or success + type: string + sample: 4096 SHA256:r4YCZxihVjedH2OlfjVGI6Y5xAYtdCwk8VxKyzVyYfM example@example.com (RSA) +''' + +import os +import errno + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native + + +class KeypairError(Exception): + pass + + +class Keypair(object): + + def __init__(self, module): + self.path = module.params['path'] + self.state = module.params['state'] + self.force = module.params['force'] + self.size = module.params['size'] + self.type = module.params['type'] + self.comment = module.params['comment'] + self.changed = False + self.check_mode = module.check_mode + self.privatekey = None + self.fingerprint = {} + + if self.type in ('rsa', 'rsa1'): + self.size = 4096 if self.size is None else self.size + if self.size < 1024: + module.fail_json(msg=('For RSA keys, the minimum size is 1024 bits and the default is 4096 bits. ' + 'Attempting to use bit lengths under 1024 will cause the module to fail.')) + + if self.type == 'dsa': + self.size = 1024 if self.size is None else self.size + if self.size != 1024: + module.fail_json(msg=('DSA keys must be exactly 1024 bits as specified by FIPS 186-2.')) + + if self.type == 'ecdsa': + self.size = 256 if self.size is None else self.size + if self.size not in (256, 384, 521): + module.fail_json(msg=('For ECDSA keys, size determines the key length by selecting from ' + 'one of three elliptic curve sizes: 256, 384 or 521 bits. ' + 'Attempting to use bit lengths other than these three values for ' + 'ECDSA keys will cause this module to fail. ')) + if self.type == 'ed25519': + self.size = 256 + + def generate(self, module): + # generate a keypair + if not self.isValid(module, perms_required=False) or self.force: + args = [ + module.get_bin_path('ssh-keygen', True), + '-q', + '-N', '', + '-b', str(self.size), + '-t', self.type, + '-f', self.path, + ] + + if self.comment: + args.extend(['-C', self.comment]) + else: + args.extend(['-C', ""]) + + try: + self.changed = True + module.run_command(args) + proc = module.run_command([module.get_bin_path('ssh-keygen', True), '-lf', self.path]) + self.fingerprint = proc[1].split() + except Exception as e: + self.remove() + module.fail_json(msg="%s" % to_native(e)) + + file_args = module.load_file_common_arguments(module.params) + if module.set_fs_attributes_if_different(file_args, False): + self.changed = True + + def isValid(self, module, perms_required=True): + + # check if the key is correct + def _check_state(): + return os.path.exists(self.path) + + if _check_state(): + proc = module.run_command([module.get_bin_path('ssh-keygen', True), '-lf', self.path]) + fingerprint = proc[1].split() + keysize = int(fingerprint[0]) + keytype = fingerprint[-1][1:-1].lower() + else: + return False + + def _check_perms(module): + file_args = module.load_file_common_arguments(module.params) + return not module.set_fs_attributes_if_different(file_args, False) + + def _check_type(): + return self.type == keytype + + def _check_size(): + return self.size == keysize + + self.fingerprint = fingerprint + + if not perms_required: + return _check_state() and _check_type() and _check_size() + + return _check_state() and _check_perms(module) and _check_type() and _check_size() + + def dump(self): + # return result as a dict + + """Serialize the object into a dictionary.""" + + result = { + 'changed': self.changed, + 'size': self.size, + 'type': self.type, + 'filename': self.path, + 'fingerprint': self.fingerprint, + } + + return result + + def remove(self): + """Remove the resource from the filesystem.""" + + try: + os.remove(self.path) + self.changed = True + except OSError as exc: + if exc.errno != errno.ENOENT: + raise KeypairError(exc) + else: + pass + + if os.path.exists(self.path + ".pub"): + try: + os.remove(self.path + ".pub") + self.changed = True + except OSError as exc: + if exc.errno != errno.ENOENT: + raise KeypairError(exc) + else: + pass + + +def main(): + + # Define Ansible Module + module = AnsibleModule( + argument_spec=dict( + state=dict(default='present', choices=['present', 'absent'], type='str'), + size=dict(type='int'), + type=dict(default='rsa', choices=['rsa', 'dsa', 'rsa1', 'ecdsa', 'ed25519'], type='str'), + force=dict(default=False, type='bool'), + path=dict(required=True, type='path'), + comment=dict(type='str'), + ), + supports_check_mode=True, + add_file_common_args=True, + ) + + # Check if Path exists + base_dir = os.path.dirname(module.params['path']) + if not os.path.isdir(base_dir): + module.fail_json( + name=base_dir, + msg='The directory %s does not exist or the file is not a directory' % base_dir + ) + + keypair = Keypair(module) + + if keypair.state == 'present': + + if module.check_mode: + result = keypair.dump() + result['changed'] = module.params['force'] or not keypair.isValid(module) + module.exit_json(**result) + + try: + keypair.generate(module) + except Exception as exc: + module.fail_json(msg=to_native(exc)) + else: + + if module.check_mode: + keypair.changed = os.path.exists(module.params['path']) + if keypair.changed: + keypair.fingerprint = {} + result = keypair.dump() + module.exit_json(**result) + + try: + keypair.remove() + except Exception as exc: + module.fail_json(msg=to_native(exc)) + + result = keypair.dump() + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/openssh_keypair/aliases b/test/integration/targets/openssh_keypair/aliases new file mode 100644 index 00000000000..6eae8bd8ddc --- /dev/null +++ b/test/integration/targets/openssh_keypair/aliases @@ -0,0 +1,2 @@ +shippable/posix/group1 +destructive diff --git a/test/integration/targets/openssh_keypair/meta/main.yml b/test/integration/targets/openssh_keypair/meta/main.yml new file mode 100644 index 00000000000..dc973f4e00a --- /dev/null +++ b/test/integration/targets/openssh_keypair/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_ssh_keygen diff --git a/test/integration/targets/openssh_keypair/tasks/main.yml b/test/integration/targets/openssh_keypair/tasks/main.yml new file mode 100644 index 00000000000..a9f5877d43f --- /dev/null +++ b/test/integration/targets/openssh_keypair/tasks/main.yml @@ -0,0 +1,25 @@ +- name: Generate privatekey1 - standard + connection: local + openssh_keypair: + path: '{{ output_dir }}/privatekey1' + +- name: Generate privatekey2 - size 2048 + openssh_keypair: + path: '{{ output_dir }}/privatekey2' + size: 2048 + +- name: Generate privatekey3 - type dsa + openssh_keypair: + path: '{{ output_dir }}/privatekey3' + type: dsa + +- name: Generate privatekey4 - standard + openssh_keypair: + path: '{{ output_dir }}/privatekey4' + +- name: Delete privatekey4 - standard + openssh_keypair: + state: absent + path: '{{ output_dir }}/privatekey4' + +- import_tasks: ../tests/validate.yml diff --git a/test/integration/targets/openssh_keypair/tests/validate.yml b/test/integration/targets/openssh_keypair/tests/validate.yml new file mode 100644 index 00000000000..51c3d7ce5ba --- /dev/null +++ b/test/integration/targets/openssh_keypair/tests/validate.yml @@ -0,0 +1,39 @@ +- name: Validate privatekey1 (test - RSA key with size 4096 bits) + shell: "ssh-keygen -lf {{ output_dir }}/privatekey1 | grep -o -E '^[0-9]+'" + register: privatekey1 + +- name: Validate privatekey1 (assert - RSA key with size 4096 bits) + assert: + that: + - privatekey1.stdout == '4096' + + +- name: Validate privatekey2 (test - RSA key with size 2048 bits) + shell: "ssh-keygen -lf {{ output_dir }}/privatekey2 | grep -o -E '^[0-9]+'" + register: privatekey2 + +- name: Validate privatekey2 (assert - RSA key with size 2048 bits) + assert: + that: + - privatekey2.stdout == '2048' + + +- name: Validate privatekey3 (test - DSA key with size 1024 bits) + shell: "ssh-keygen -lf {{ output_dir }}/privatekey3 | grep -o -E '^[0-9]+'" + register: privatekey3 + +- name: Validate privatekey3 (assert - DSA key with size 4096 bits) + assert: + that: + - privatekey3.stdout == '1024' + + +- name: Validate privatekey4 (test - Ensure key has been removed) + stat: + path: '{{ output_dir }}/privatekey4' + register: privatekey4 + +- name: Validate privatekey4 (assert - Ensure key has been removed) + assert: + that: + - privatekey4.stat.exists == False diff --git a/test/integration/targets/setup_ssh_keygen/tasks/main.yml b/test/integration/targets/setup_ssh_keygen/tasks/main.yml new file mode 100644 index 00000000000..885356d7894 --- /dev/null +++ b/test/integration/targets/setup_ssh_keygen/tasks/main.yml @@ -0,0 +1,8 @@ +- name: Include OS-specific variables + include_vars: '{{ ansible_os_family }}.yml' + when: not ansible_os_family == "Darwin" and not ansible_os_family == "FreeBSD" + +- name: Install ssh-keygen + package: + name: '{{ openssh_client_package_name }}' + when: not ansible_os_family == "Darwin" and not ansible_os_family == "FreeBSD" diff --git a/test/integration/targets/setup_ssh_keygen/vars/Debian.yml b/test/integration/targets/setup_ssh_keygen/vars/Debian.yml new file mode 100644 index 00000000000..d7ff0c734e7 --- /dev/null +++ b/test/integration/targets/setup_ssh_keygen/vars/Debian.yml @@ -0,0 +1 @@ +openssh_client_package_name: openssh-client diff --git a/test/integration/targets/setup_ssh_keygen/vars/RedHat.yml b/test/integration/targets/setup_ssh_keygen/vars/RedHat.yml new file mode 100644 index 00000000000..bc656edfad5 --- /dev/null +++ b/test/integration/targets/setup_ssh_keygen/vars/RedHat.yml @@ -0,0 +1 @@ +openssh_client_package_name: openssh-clients diff --git a/test/integration/targets/setup_ssh_keygen/vars/Suse.yml b/test/integration/targets/setup_ssh_keygen/vars/Suse.yml new file mode 100644 index 00000000000..4091fa7b8b2 --- /dev/null +++ b/test/integration/targets/setup_ssh_keygen/vars/Suse.yml @@ -0,0 +1 @@ +openssh_client_package_name: openssh