Repository: ansible/ansible

Issue:
Generate Diffie-Hellman Parameters with openssl module
##### ISSUE TYPE
<!--- Pick one below and delete the rest: -->
 - Feature Idea

##### COMPONENT NAME
module openssl_*

##### SUMMARY

There are various commands to generate keys and certificates. It would be helpful to also be able to easily generate Diffie-Hellman parameters from Ansible.

##### Example
<!---
For bugs, show exactly how to reproduce the problem, using a minimal test-case.
For new features, show how the feature would be used.
-->

<!--- Paste example playbooks or commands between quotes below -->
```yaml
- name: Generate DH params
  openssl_dhparam:
    path: /path/dhparam.pem
    bits: 4096
# openssl genpkey -genparam -algorithm DH -out /path/dhparam.pem -pkeyopt dh_paramgen_prime_len:4096
```



Discussion:
cc'ing @Spredzy @MarkusTeufelberger 
Sounds like a nice idea. :-)

Do you want to implement this yourself or are you looking for someone who wants to do this?
I guess I could take a stab at it but I'm not sure I'd have time to do it right away.

Resolving patch:
diff --git a/lib/ansible/modules/crypto/openssl_dhparam.py b/lib/ansible/modules/crypto/openssl_dhparam.py
new file mode 100644
--- /dev/null
+++ b/lib/ansible/modules/crypto/openssl_dhparam.py
@@ -0,0 +1,232 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# (c) 2017, Thom Wiggers  <ansible@thomwiggers.nl>
+# 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: openssl_dhparam
+author: "Thom Wiggers (@thomwiggers)"
+version_added: "2.5"
+short_description: Generate OpenSSL Diffie-Hellman Parameters
+description:
+    - "This module allows one to (re)generate OpenSSL DH-params.
+      This module uses file common arguments to specify generated file permissions."
+requirements:
+    - OpenSSL
+options:
+    state:
+        required: false
+        default: "present"
+        choices: [ present, absent ]
+        description:
+            - Whether the parameters should exist or not,
+              taking action if the state is different from what is stated.
+    size:
+        required: false
+        default: 4096
+        description:
+            - Size (in bits) of the generated DH-params
+    force:
+        required: false
+        default: False
+        choices: [ True, False ]
+        description:
+            - Should the parameters be regenerated even it it already exists
+    path:
+        required: true
+        description:
+            - Name of the file in which the generated parameters will be saved.
+extends_documentation_fragment: files
+'''
+
+EXAMPLES = '''
+# Generate Diffie-Hellman parameters with the default size (4096 bits)
+- openssl_dhparam:
+    path: /etc/ssl/dhparams.pem
+
+# Generate DH Parameters with a different size (2048 bits)
+- openssl_dhparam:
+    path: /etc/ssl/dhparams.pem
+    size: 2048
+
+# Force regenerate an DH parameters if they already exist
+- openssl_dhparam:
+    path: /etc/ssl/dhparams.pem
+    force: True
+
+'''
+
+RETURN = '''
+size:
+    description: Size (in bits) of the Diffie-Hellman parameters
+    returned: changed or success
+    type: int
+    sample: 4096
+filename:
+    description: Path to the generated Diffie-Hellman parameters
+    returned: changed or success
+    type: string
+    sample: /etc/ssl/dhparams.pem
+'''
+
+import os
+import re
+import tempfile
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils._text import to_native
+
+
+class DHParameterError(Exception):
+    pass
+
+
+class DHParameter(object):
+
+    def __init__(self, module):
+        self.state = module.params['state']
+        self.path = module.params['path']
+        self.size = int(module.params['size'])
+        self.force = module.params['force']
+        self.changed = False
+        self.openssl_bin = module.get_bin_path('openssl', True)
+
+    def generate(self, module):
+        """Generate a keypair."""
+        changed = False
+
+        # ony generate when necessary
+        if self.force or not self._check_params_valid(module):
+            # create a tempfile
+            fd, tmpsrc = tempfile.mkstemp()
+            os.close(fd)
+            module.add_cleanup_file(tmpsrc)  # Ansible will delete the file on exit
+            # openssl dhparam -out <path> <bits>
+            command = [self.openssl_bin, 'dhparam', '-out', tmpsrc, str(self.size)]
+            rc, dummy, err = module.run_command(command, check_rc=False)
+            if rc != 0:
+                raise DHParameterError(to_native(err))
+            try:
+                module.atomic_move(tmpsrc, self.path)
+            except Exception as e:
+                module.fail_json(msg="Failed to write to file %s: %s" % (self.path, str(e)))
+            changed = True
+
+        # fix permissions (checking force not necessary as done above)
+        if not self._check_fs_attributes(module):
+            # Fix done implicitly by
+            # AnsibleModule.set_fs_attributes_if_different
+            changed = True
+
+        self.changed = changed
+
+    def check(self, module):
+        """Ensure the resource is in its desired state."""
+        if self.force:
+            return False
+        return self._check_params_valid(module) and self._check_fs_attributes(module)
+
+    def _check_params_valid(self, module):
+        """Check if the params are in the correct state"""
+        command = [self.openssl_bin, 'dhparam', '-check', '-text', '-noout', '-in', self.path]
+        rc, out, err = module.run_command(command, check_rc=False)
+        result = to_native(out)
+        if rc != 0:
+            # If the call failed the file probably doesn't exist or is
+            # unreadable
+            return False
+        # output contains "(xxxx bit)"
+        match = re.search(r"Parameters:\s+\((\d+) bit\).*", result)
+        if not match:
+            return False  # No "xxxx bit" in output
+        else:
+            bits = int(match.group(1))
+
+        # if output contains "WARNING" we've got a problem
+        if "WARNING" in result or "WARNING" in to_native(err):
+            return False
+
+        return bits == self.size
+
+    def _check_fs_attributes(self, module):
+        """Checks (and changes if not in check mode!) fs attributes"""
+        file_args = module.load_file_common_arguments(module.params)
+        attrs_changed = module.set_fs_attributes_if_different(file_args, False)
+
+        return not attrs_changed
+
+    def dump(self):
+        """Serialize the object into a dictionary."""
+
+        result = {
+            'size': self.size,
+            'filename': self.path,
+            'changed': self.changed,
+        }
+        return result
+
+
+def main():
+    """Main function"""
+
+    module = AnsibleModule(
+        argument_spec=dict(
+            state=dict(default='present', choices=['present', 'absent'], type='str'),
+            size=dict(default=4096, type='int'),
+            force=dict(default=False, type='bool'),
+            path=dict(required=True, type='path'),
+        ),
+        supports_check_mode=True,
+        add_file_common_args=True,
+    )
+
+    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
+        )
+
+    dhparam = DHParameter(module)
+
+    if dhparam.state == 'present':
+
+        if module.check_mode:
+            result = dhparam.dump()
+            result['changed'] = module.params['force'] or not dhparam.check(module)
+            module.exit_json(**result)
+
+        try:
+            dhparam.generate(module)
+        except DHParameterError as exc:
+            module.fail_json(msg=to_native(exc))
+    else:
+
+        if module.check_mode:
+            result = dhparam.dump()
+            result['changed'] = os.path.exists(module.params['path'])
+            module.exit_json(**result)
+
+        try:
+            os.remove(module.params['path'])
+        except OSError as exc:
+            module.fail_json(msg=to_native(exc))
+
+    result = dhparam.dump()
+
+    module.exit_json(**result)
+
+
+if __name__ == '__main__':
+    main()


Test patch:
diff --git a/test/integration/targets/openssl_dhparam/aliases b/test/integration/targets/openssl_dhparam/aliases
new file mode 100644
--- /dev/null
+++ b/test/integration/targets/openssl_dhparam/aliases
@@ -0,0 +1,2 @@
+posix/ci/group1
+destructive
diff --git a/test/integration/targets/openssl_dhparam/tasks/main.yml b/test/integration/targets/openssl_dhparam/tasks/main.yml
new file mode 100644
--- /dev/null
+++ b/test/integration/targets/openssl_dhparam/tasks/main.yml
@@ -0,0 +1,44 @@
+- block:
+    # This module generates unsafe parameters for testing purposes
+    # otherwise tests would be too slow
+    - name: Generate parameter
+      openssl_dhparam:
+        size: 768
+        path: '{{ output_dir }}/dh768.pem'
+
+    - name: Don't regenerate parameters with no change
+      openssl_dhparam:
+        size: 768
+        path: '{{ output_dir }}/dh768.pem'
+      register: dhparam_changed
+
+    - name: Generate parameters with size option
+      openssl_dhparam:
+        path: '{{ output_dir }}/dh512.pem'
+        size: 512
+
+    - name: Don't regenerate parameters with size option and no change
+      openssl_dhparam:
+        path: '{{ output_dir }}/dh512.pem'
+        size: 512
+      register: dhparam_changed_512
+
+    - copy:
+        src: '{{ output_dir }}/dh768.pem'
+        remote_src: yes
+        dest: '{{ output_dir }}/dh512.pem'
+
+    - name: Re-generate if size is different
+      openssl_dhparam:
+        path: '{{ output_dir }}/dh512.pem'
+        size: 512
+      register: dhparam_changed_to_512
+
+    - name: Force re-generate parameters with size option
+      openssl_dhparam:
+        path: '{{ output_dir }}/dh512.pem'
+        size: 512
+        force: yes
+      register: dhparam_changed_force
+
+    - import_tasks: ../tests/validate.yml
diff --git a/test/integration/targets/openssl_dhparam/tests/validate.yml b/test/integration/targets/openssl_dhparam/tests/validate.yml
new file mode 100644
--- /dev/null
+++ b/test/integration/targets/openssl_dhparam/tests/validate.yml
@@ -0,0 +1,32 @@
+---
+- name: Validate generated params
+  shell: 'openssl dhparam -in {{ output_dir }}/{{ item }}.pem -noout -check'
+  with_items:
+    - dh768
+    - dh512
+
+- name: Get bit size of 768
+  shell: 'openssl dhparam -noout -in {{ output_dir }}/dh768.pem -text | head -n1 | sed -ne "s@.*(\\([[:digit:]]\{1,\}\\) bit).*@\\1@p"'
+  register: bit_size_dhparam
+
+- name: Check bit size of default
+  assert:
+    that:
+      - bit_size_dhparam.stdout == "768"
+
+- name: Get bit size of 512
+  shell: 'openssl dhparam -noout -in {{ output_dir }}/dh512.pem -text | head -n1 | sed -ne "s@.*(\\([[:digit:]]\{1,\}\\) bit).*@\\1@p"'
+  register: bit_size_dhparam_512
+
+- name: Check bit size of default
+  assert:
+    that:
+      - bit_size_dhparam_512.stdout == "512"
+
+- name: Check if changed works correctly
+  assert:
+    that:
+      - dhparam_changed is not changed
+      - dhparam_changed_512 is not changed
+      - dhparam_changed_to_512 is changed
+      - dhparam_changed_force is changed