Beispiel #1
0
def main():
    module = AnsibleModule(
        argument_spec=dict(
            account_key_src=dict(type='path', aliases=['account_key']),
            account_key_content=dict(type='str', no_log=True),
            account_uri=dict(type='str'),
            acme_directory=dict(
                type='str',
                default='https://acme-staging.api.letsencrypt.org/directory'),
            acme_version=dict(type='int', default=1, choices=[1, 2]),
            validate_certs=dict(type='bool', default=True),
            select_crypto_backend=dict(
                type='str',
                default='auto',
                choices=['auto', 'openssl', 'cryptography']),
        ),
        required_one_of=(['account_key_src', 'account_key_content'], ),
        mutually_exclusive=(['account_key_src', 'account_key_content'], ),
        supports_check_mode=True,
    )
    set_crypto_backend(module)

    if not module.params.get('validate_certs'):
        module.warn(
            warning=
            'Disabling certificate validation for communications with ACME endpoint. '
            +
            'This should only be done for testing against a local ACME server for '
            + 'development purposes, but *never* for production purposes.')
    if module.params.get('acme_version') < 2:
        module.fail_json(
            msg='The acme_account module requires the ACME v2 protocol!')

    try:
        account = ACMEAccount(module)
        # Check whether account exists
        created, account_data = account.setup_account(
            [],
            allow_creation=False,
            remove_account_uri_if_not_exists=True,
        )
        if created:
            raise AssertionError('Unwanted account creation')
        result = {
            'changed': False,
            'exists': account.uri is not None,
            'account_uri': account.uri,
        }
        if account.uri is not None:
            # Make sure promised data is there
            if 'contact' not in account_data:
                account_data['contact'] = []
            account_data['public_account_key'] = account.key_data['jwk']
            result['account'] = account_data
        module.exit_json(**result)
    except ModuleFailException as e:
        e.do_fail(module)
Beispiel #2
0
def main():
    argument_spec = get_default_argspec()
    argument_spec.update(
        dict(retrieve_orders=dict(
            type='str',
            default='ignore',
            choices=['ignore', 'url_list', 'object_list']), ))
    module = AnsibleModule(
        argument_spec=argument_spec,
        required_one_of=(['account_key_src', 'account_key_content'], ),
        mutually_exclusive=(['account_key_src', 'account_key_content'], ),
        supports_check_mode=True,
    )
    if module._name == 'acme_account_facts':
        module.deprecate(
            "The 'acme_account_facts' module has been renamed to 'acme_account_info'",
            version='2.12')
    handle_standard_module_arguments(module, needs_acme_v2=True)

    try:
        account = ACMEAccount(module)
        # Check whether account exists
        created, account_data = account.setup_account(
            [],
            allow_creation=False,
            remove_account_uri_if_not_exists=True,
        )
        if created:
            raise AssertionError('Unwanted account creation')
        result = {
            'changed': False,
            'exists': account.uri is not None,
            'account_uri': account.uri,
        }
        if account.uri is not None:
            # Make sure promised data is there
            if 'contact' not in account_data:
                account_data['contact'] = []
            account_data['public_account_key'] = account.key_data['jwk']
            result['account'] = account_data
            # Retrieve orders list
            if account_data.get(
                    'orders') and module.params['retrieve_orders'] != 'ignore':
                orders = get_orders_list(module, account,
                                         account_data['orders'])
                if module.params['retrieve_orders'] == 'url_list':
                    result['orders'] = orders
                else:
                    result['orders'] = [
                        get_order(account, order) for order in orders
                    ]
        module.exit_json(**result)
    except ModuleFailException as e:
        e.do_fail(module)
Beispiel #3
0
    def __init__(self, module):
        self.module = module
        self.version = module.params['acme_version']
        self.challenge = module.params['challenge']
        self.csr = module.params['csr']
        self.dest = module.params.get('dest')
        self.fullchain_dest = module.params.get('fullchain_dest')
        self.chain_dest = module.params.get('chain_dest')
        self.account = ACMEAccount(module)
        self.directory = self.account.directory
        self.data = module.params['data']
        self.authorizations = None
        self.cert_days = -1
        self.order_uri = self.data.get('order_uri') if self.data else None
        self.finalize_uri = None

        # Make sure account exists
        modify_account = module.params['modify_account']
        if modify_account or self.version > 1:
            contact = []
            if module.params['account_email']:
                contact.append('mailto:' + module.params['account_email'])
            created, account_data = self.account.setup_account(
                contact,
                agreement=module.params.get('agreement'),
                terms_agreed=module.params.get('terms_agreed'),
                allow_creation=modify_account,
            )
            if account_data is None:
                raise ModuleFailException(
                    msg='Account does not exist or is deactivated.')
            updated = False
            if not created and account_data and modify_account:
                updated, account_data = self.account.update_account(
                    account_data, contact)
            self.changed = created or updated
        else:
            # This happens if modify_account is False and the ACME v1
            # protocol is used. In this case, we do not call setup_account()
            # to avoid accidental creation of an account. This is OK
            # since for ACME v1, the account URI is not needed to send a
            # signed ACME request.
            pass

        if not os.path.exists(self.csr):
            raise ModuleFailException("CSR %s not found" % (self.csr))

        self._openssl_bin = module.get_bin_path('openssl', True)

        # Extract list of identifiers from CSR
        self.identifiers = self._get_csr_identifiers()
class ACMEClient(object):
    '''
    ACME client class. Uses an ACME account object and a CSR to
    start and validate ACME challenges and download the respective
    certificates.
    '''
    def __init__(self, module):
        self.module = module
        self.version = module.params['acme_version']
        self.challenge = module.params['challenge']
        self.csr = module.params['csr']
        self.dest = module.params.get('dest')
        self.fullchain_dest = module.params.get('fullchain_dest')
        self.chain_dest = module.params.get('chain_dest')
        self.account = ACMEAccount(module)
        self.directory = self.account.directory
        self.data = module.params['data']
        self.authorizations = None
        self.cert_days = -1
        self.order_uri = self.data.get('order_uri') if self.data else None
        self.finalize_uri = None

        # Make sure account exists
        modify_account = module.params['modify_account']
        if modify_account or self.version > 1:
            contact = []
            if module.params['account_email']:
                contact.append('mailto:' + module.params['account_email'])
            self.changed = self.account.init_account(
                contact,
                agreement=module.params.get('agreement'),
                terms_agreed=module.params.get('terms_agreed'),
                allow_creation=modify_account,
                update_contact=modify_account)
        else:
            # This happens if modify_account is False and the ACME v1
            # protocol is used. In this case, we do not call init_account()
            # to avoid accidental creation of an account. This is OK
            # since for ACME v1, the account URI is not needed to send a
            # signed ACME request.
            pass

        # Extract list of domains from CSR
        if not os.path.exists(self.csr):
            raise ModuleFailException("CSR %s not found" % (self.csr))

        self._openssl_bin = module.get_bin_path('openssl', True)
        self.domains = self._get_csr_domains()

    def _get_csr_domains(self):
        '''
        Parse the CSR and return the list of requested domains
        '''
        if HAS_CURRENT_CRYPTOGRAPHY:
            return cryptography_get_csr_domains(self.module, self.csr)
        openssl_csr_cmd = [
            self._openssl_bin, "req", "-in", self.csr, "-noout", "-text"
        ]
        dummy, out, dummy = self.module.run_command(openssl_csr_cmd,
                                                    check_rc=True)

        domains = set([])
        common_name = re.search(r"Subject:.*? CN\s?=\s?([^\s,;/]+)",
                                to_text(out, errors='surrogate_or_strict'))
        if common_name is not None:
            domains.add(common_name.group(1))
        subject_alt_names = re.search(
            r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n",
            to_text(out, errors='surrogate_or_strict'),
            re.MULTILINE | re.DOTALL)
        if subject_alt_names is not None:
            for san in subject_alt_names.group(1).split(", "):
                if san.startswith("DNS:"):
                    domains.add(san[4:])
        return domains

    def _add_or_update_auth(self, domain, auth):
        '''
        Add or update the given authroization in the global authorizations list.
        Return True if the auth was updated/added and False if no change was
        necessary.
        '''
        if self.authorizations.get(domain) == auth:
            return False
        self.authorizations[domain] = auth
        return True

    def _new_authz_v1(self, domain):
        '''
        Create a new authorization for the given domain.
        Return the authorization object of the new authorization
        https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4
        '''
        if self.account.uri is None:
            return

        new_authz = {
            "resource": "new-authz",
            "identifier": {
                "type": "dns",
                "value": domain
            },
        }

        result, info = self.account.send_signed_request(
            self.directory['new-authz'], new_authz)
        if info['status'] not in [200, 201]:
            raise ModuleFailException(
                "Error requesting challenges: CODE: {0} RESULT: {1}".format(
                    info['status'], result))
        else:
            result['uri'] = info['location']
            return result

    def _get_challenge_data(self, auth, domain):
        '''
        Returns a dict with the data for all proposed (and supported) challenges
        of the given authorization.
        '''

        data = {}
        # no need to choose a specific challenge here as this module
        # is not responsible for fulfilling the challenges. Calculate
        # and return the required information for each challenge.
        for challenge in auth['challenges']:
            type = challenge['type']
            token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token'])
            keyauthorization = self.account.get_keyauthorization(token)

            if type == 'http-01':
                # https://tools.ietf.org/html/rfc8555#section-8.3
                resource = '.well-known/acme-challenge/' + token
                data[type] = {
                    'resource': resource,
                    'resource_value': keyauthorization
                }
            elif type == 'dns-01':
                # https://tools.ietf.org/html/rfc8555#section-8.4
                resource = '_acme-challenge'
                value = nopad_b64(
                    hashlib.sha256(to_bytes(keyauthorization)).digest())
                record = (resource +
                          domain[1:]) if domain.startswith('*.') else (
                              resource + '.' + domain)
                data[type] = {
                    'resource': resource,
                    'resource_value': value,
                    'record': record
                }
            elif type == 'tls-alpn-01':
                # https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#section-3
                resource = domain
                value = base64.b64encode(
                    hashlib.sha256(to_bytes(keyauthorization)).digest())
                data[type] = {'resource': resource, 'resource_value': value}
            else:
                continue

        return data

    def _fail_challenge(self, domain, auth, error):
        '''
        Aborts with a specific error for a challenge.
        '''
        error_details = ''
        # multiple challenges could have failed at this point, gather error
        # details for all of them before failing
        for challenge in auth['challenges']:
            if challenge['status'] == 'invalid':
                error_details += ' CHALLENGE: {0}'.format(challenge['type'])
                if 'error' in challenge:
                    error_details += ' DETAILS: {0};'.format(
                        challenge['error']['detail'])
                else:
                    error_details += ';'
        raise ModuleFailException("{0}: {1}".format(error.format(domain),
                                                    error_details))

    def _validate_challenges(self, domain, auth):
        '''
        Validate the authorization provided in the auth dict. Returns True
        when the validation was successful and False when it was not.
        '''
        for challenge in auth['challenges']:
            if self.challenge != challenge['type']:
                continue

            uri = challenge['uri'] if self.version == 1 else challenge['url']

            challenge_response = {}
            if self.version == 1:
                token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token'])
                keyauthorization = self.account.get_keyauthorization(token)
                challenge_response["resource"] = "challenge"
                challenge_response["keyAuthorization"] = keyauthorization
            result, info = self.account.send_signed_request(
                uri, challenge_response)
            if info['status'] not in [200, 202]:
                raise ModuleFailException(
                    "Error validating challenge: CODE: {0} RESULT: {1}".format(
                        info['status'], result))

        status = ''

        while status not in ['valid', 'invalid', 'revoked']:
            result, dummy = self.account.get_request(auth['uri'])
            result['uri'] = auth['uri']
            if self._add_or_update_auth(domain, result):
                self.changed = True
            # https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.1.2
            # "status (required, string): ...
            # If this field is missing, then the default value is "pending"."
            if self.version == 1 and 'status' not in result:
                status = 'pending'
            else:
                status = result['status']
            time.sleep(2)

        if status == 'invalid':
            self._fail_challenge(domain, result,
                                 'Authorization for {0} returned invalid')

        return status == 'valid'

    def _finalize_cert(self):
        '''
        Create a new certificate based on the csr.
        Return the certificate object as dict
        https://tools.ietf.org/html/rfc8555#section-7.4
        '''
        csr = pem_to_der(self.csr)
        new_cert = {
            "csr": nopad_b64(csr),
        }
        result, info = self.account.send_signed_request(
            self.finalize_uri, new_cert)
        if info['status'] not in [200]:
            raise ModuleFailException(
                "Error new cert: CODE: {0} RESULT: {1}".format(
                    info['status'], result))

        order = info['location']

        status = result['status']
        while status not in ['valid', 'invalid']:
            time.sleep(2)
            result, dummy = self.account.get_request(order)
            status = result['status']

        if status != 'valid':
            raise ModuleFailException(
                "Error new cert: CODE: {0} STATUS: {1} RESULT: {2}".format(
                    info['status'], status, result))

        return result['certificate']

    def _der_to_pem(self, der_cert):
        '''
        Convert the DER format certificate in der_cert to a PEM format
        certificate and return it.
        '''
        return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format(
            "\n".join(
                textwrap.wrap(base64.b64encode(der_cert).decode('utf8'), 64)))

    def _download_cert(self, url):
        '''
        Download and parse the certificate chain.
        https://tools.ietf.org/html/rfc8555#section-7.4.2
        '''
        content, info = self.account.get_request(
            url,
            parse_json_result=False,
            headers={'Accept': 'application/pem-certificate-chain'})

        if not content or not info['content-type'].startswith(
                'application/pem-certificate-chain'):
            raise ModuleFailException(
                "Cannot download certificate chain from {0}: {1} (headers: {2})"
                .format(url, content, info))

        cert = None
        chain = []

        # Parse data
        lines = content.decode('utf-8').splitlines(True)
        current = []
        for line in lines:
            if line.strip():
                current.append(line)
            if line.startswith('-----END CERTIFICATE-----'):
                if cert is None:
                    cert = ''.join(current)
                else:
                    chain.append(''.join(current))
                current = []

        # Process link-up headers if there was no chain in reply
        if not chain and 'link' in info:
            link = info['link']
            parsed_link = re.match(r'<(.+)>;rel="(\w+)"', link)
            if parsed_link and parsed_link.group(2) == "up":
                chain_link = parsed_link.group(1)
                chain_result, chain_info = self.account.get_request(
                    chain_link, parse_json_result=False)
                if chain_info['status'] in [200, 201]:
                    chain.append(self._der_to_pem(chain_result))

        if cert is None or current:
            raise ModuleFailException(
                "Failed to parse certificate chain download from {0}: {1} (headers: {2})"
                .format(url, content, info))
        return {'cert': cert, 'chain': chain}

    def _new_cert_v1(self):
        '''
        Create a new certificate based on the CSR (ACME v1 protocol).
        Return the certificate object as dict
        https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.5
        '''
        csr = pem_to_der(self.csr)
        new_cert = {
            "resource": "new-cert",
            "csr": nopad_b64(csr),
        }
        result, info = self.account.send_signed_request(
            self.directory['new-cert'], new_cert)

        chain = []
        if 'link' in info:
            link = info['link']
            parsed_link = re.match(r'<(.+)>;rel="(\w+)"', link)
            if parsed_link and parsed_link.group(2) == "up":
                chain_link = parsed_link.group(1)
                chain_result, chain_info = self.account.get_request(
                    chain_link, parse_json_result=False)
                if chain_info['status'] in [200, 201]:
                    chain = [self._der_to_pem(chain_result)]

        if info['status'] not in [200, 201]:
            raise ModuleFailException(
                "Error new cert: CODE: {0} RESULT: {1}".format(
                    info['status'], result))
        else:
            return {
                'cert': self._der_to_pem(result),
                'uri': info['location'],
                'chain': chain
            }

    def _new_order_v2(self):
        '''
        Start a new certificate order (ACME v2 protocol).
        https://tools.ietf.org/html/rfc8555#section-7.4
        '''
        identifiers = []
        for domain in self.domains:
            identifiers.append({
                'type': 'dns',
                'value': domain,
            })
        new_order = {"identifiers": identifiers}
        result, info = self.account.send_signed_request(
            self.directory['newOrder'], new_order)

        if info['status'] not in [201]:
            raise ModuleFailException(
                "Error new order: CODE: {0} RESULT: {1}".format(
                    info['status'], result))

        for auth_uri in result['authorizations']:
            auth_data, dummy = self.account.get_request(auth_uri)
            auth_data['uri'] = auth_uri
            domain = auth_data['identifier']['value']
            if auth_data.get('wildcard', False):
                domain = '*.{0}'.format(domain)
            self.authorizations[domain] = auth_data

        self.order_uri = info['location']
        self.finalize_uri = result['finalize']

    def is_first_step(self):
        '''
        Return True if this is the first execution of this module, i.e. if a
        sufficient data object from a first run has not been provided.
        '''
        if self.data is None:
            return True
        if self.version == 1:
            # As soon as self.data is a non-empty object, we are in the second stage.
            return not self.data
        else:
            # We are in the second stage if data.order_uri is given (which has been
            # stored in self.order_uri by the constructor).
            return self.order_uri is None

    def start_challenges(self):
        '''
        Create new authorizations for all domains of the CSR,
        respectively start a new order for ACME v2.
        '''
        self.authorizations = {}
        if self.version == 1:
            for domain in self.domains:
                new_auth = self._new_authz_v1(domain)
                self._add_or_update_auth(domain, new_auth)
        else:
            self._new_order_v2()
        self.changed = True

    def get_challenges_data(self):
        '''
        Get challenge details for the chosen challenge type.
        Return a tuple of generic challenge details, and specialized DNS challenge details.
        '''
        # Get general challenge data
        data = {}
        for domain, auth in self.authorizations.items():
            data[domain] = self._get_challenge_data(
                self.authorizations[domain], domain)
        # Get DNS challenge data
        data_dns = {}
        if self.challenge == 'dns-01':
            for domain, challenges in data.items():
                if self.challenge in challenges:
                    values = data_dns.get(challenges[self.challenge]['record'])
                    if values is None:
                        values = []
                        data_dns[challenges[self.challenge]['record']] = values
                    values.append(challenges[self.challenge]['resource_value'])
        return data, data_dns

    def finish_challenges(self):
        '''
        Verify challenges for all domains of the CSR.
        '''
        self.authorizations = {}

        # Step 1: obtain challenge information
        if self.version == 1:
            # For ACME v1, we attempt to create new authzs. Existing ones
            # will be returned instead.
            for domain in self.domains:
                new_auth = self._new_authz_v1(domain)
                self._add_or_update_auth(domain, new_auth)
        else:
            # For ACME v2, we obtain the order object by fetching the
            # order URI, and extract the information from there.
            result, info = self.account.get_request(self.order_uri)

            if not result:
                raise ModuleFailException(
                    "Cannot download order from {0}: {1} (headers: {2})".
                    format(self.order_uri, result, info))

            if info['status'] not in [200]:
                raise ModuleFailException(
                    "Error on downloading order: CODE: {0} RESULT: {1}".format(
                        info['status'], result))

            for auth_uri in result['authorizations']:
                auth_data, dummy = self.account.get_request(auth_uri)
                auth_data['uri'] = auth_uri
                domain = auth_data['identifier']['value']
                if auth_data.get('wildcard', False):
                    domain = '*.{0}'.format(domain)
                self.authorizations[domain] = auth_data

            self.finalize_uri = result['finalize']

        # Step 2: validate challenges
        for domain, auth in self.authorizations.items():
            if auth['status'] == 'pending':
                self._validate_challenges(domain, auth)

    def get_certificate(self):
        '''
        Request a new certificate and write it to the destination file.
        First verifies whether all authorizations are valid; if not, aborts
        with an error.
        '''
        for domain in self.domains:
            auth = self.authorizations.get(domain)
            if auth is None:
                raise ModuleFailException(
                    'Found no authorization information for "{0}"!'.format(
                        domain))
            if 'status' not in auth:
                self._fail_challenge(
                    domain, auth, 'Authorization for {0} returned no status')
            if auth['status'] != 'valid':
                self._fail_challenge(
                    domain, auth, 'Authorization for {0} returned status ' +
                    str(auth['status']))

        if self.version == 1:
            cert = self._new_cert_v1()
        else:
            cert_uri = self._finalize_cert()
            cert = self._download_cert(cert_uri)

        if cert['cert'] is not None:
            pem_cert = cert['cert']

            chain = [link for link in cert.get('chain', [])]

            if self.dest and write_file(self.module, self.dest,
                                        pem_cert.encode('utf8')):
                self.cert_days = get_cert_days(self.module, self.dest)
                self.changed = True

            if self.fullchain_dest and write_file(
                    self.module, self.fullchain_dest,
                (pem_cert + "\n".join(chain)).encode('utf8')):
                self.cert_days = get_cert_days(self.module,
                                               self.fullchain_dest)
                self.changed = True

            if self.chain_dest and write_file(
                    self.module, self.chain_dest,
                ("\n".join(chain)).encode('utf8')):
                self.changed = True

    def deactivate_authzs(self):
        '''
        Deactivates all valid authz's. Does not raise exceptions.
        https://community.letsencrypt.org/t/authorization-deactivation/19860/2
        https://tools.ietf.org/html/rfc8555#section-7.5.2
        '''
        authz_deactivate = {'status': 'deactivated'}
        if self.version == 1:
            authz_deactivate['resource'] = 'authz'
        if self.authorizations:
            for domain in self.domains:
                auth = self.authorizations.get(domain)
                if auth is None or auth.get('status') != 'valid':
                    continue
                try:
                    result, info = self.account.send_signed_request(
                        auth['uri'], authz_deactivate)
                    if 200 <= info['status'] < 300 and result.get(
                            'status') == 'deactivated':
                        auth['status'] = 'deactivated'
                except Exception as e:
                    # Ignore errors on deactivating authzs
                    pass
                if auth.get('status') != 'deactivated':
                    self.module.warn(
                        warning='Could not deactivate authz object {0}.'.
                        format(auth['uri']))
Beispiel #5
0
def main():
    argument_spec = get_default_argspec()
    argument_spec.update(
        dict(
            url=dict(type='str'),
            method=dict(type='str',
                        choices=['get', 'post', 'directory-only'],
                        default='get'),
            content=dict(type='str'),
            fail_on_acme_error=dict(type='bool', default=True),
        ))
    module = AnsibleModule(
        argument_spec=argument_spec,
        mutually_exclusive=(['account_key_src', 'account_key_content'], ),
        required_if=(
            ['method', 'get', ['url']],
            ['method', 'post', ['url', 'content']],
            [
                'method', 'get', ['account_key_src', 'account_key_content'],
                True
            ],
            [
                'method', 'post', ['account_key_src', 'account_key_content'],
                True
            ],
        ),
    )
    handle_standard_module_arguments(module)

    result = dict()
    changed = False
    try:
        # Get hold of ACMEAccount object (includes directory)
        account = ACMEAccount(module)
        method = module.params['method']
        result['directory'] = account.directory.directory
        # Do we have to do more requests?
        if method != 'directory-only':
            url = module.params['url']
            fail_on_acme_error = module.params['fail_on_acme_error']
            # Do request
            if method == 'get':
                data, info = account.get_request(url,
                                                 parse_json_result=False,
                                                 fail_on_error=False)
            elif method == 'post':
                changed = True  # only POSTs can change
                data, info = account.send_signed_request(
                    url,
                    to_bytes(module.params['content']),
                    parse_json_result=False,
                    encode_payload=False)
            # Update results
            result.update(dict(
                headers=info,
                output_text=to_native(data),
            ))
            # See if we can parse the result as JSON
            try:
                result['output_json'] = json.loads(data)
            except Exception as dummy:
                pass
            # Fail if error was returned
            if fail_on_acme_error and info['status'] >= 400:
                raise ModuleFailException(
                    "ACME request failed: CODE: {0} RESULT: {1}".format(
                        info['status'], data))
        # Done!
        module.exit_json(changed=changed, **result)
    except ModuleFailException as e:
        e.do_fail(module, **result)
Beispiel #6
0
def main():
    module = AnsibleModule(
        argument_spec=dict(
            account_key_src=dict(type='path', aliases=['account_key']),
            account_key_content=dict(type='str', no_log=True),
            account_uri=dict(required=False, type='str'),
            acme_directory=dict(
                required=False,
                default='https://acme-staging.api.letsencrypt.org/directory',
                type='str'),
            acme_version=dict(required=False,
                              default=1,
                              choices=[1, 2],
                              type='int'),
            validate_certs=dict(required=False, default=True, type='bool'),
            private_key_src=dict(type='path'),
            private_key_content=dict(type='str', no_log=True),
            certificate=dict(required=True, type='path'),
            revoke_reason=dict(required=False, type='int'),
            select_crypto_backend=dict(
                required=False,
                choices=['auto', 'openssl', 'cryptography'],
                default='auto',
                type='str'),
        ),
        required_one_of=([
            'account_key_src', 'account_key_content', 'private_key_src',
            'private_key_content'
        ], ),
        mutually_exclusive=([
            'account_key_src', 'account_key_content', 'private_key_src',
            'private_key_content'
        ], ),
        supports_check_mode=False,
    )
    set_crypto_backend(module)

    if not module.params.get('validate_certs'):
        module.warn(
            warning=
            'Disabling certificate validation for communications with ACME endpoint. '
            +
            'This should only be done for testing against a local ACME server for '
            + 'development purposes, but *never* for production purposes.')

    try:
        account = ACMEAccount(module)
        # Load certificate
        certificate = pem_to_der(module.params.get('certificate'))
        certificate = nopad_b64(certificate)
        # Construct payload
        payload = {'certificate': certificate}
        if module.params.get('revoke_reason') is not None:
            payload['reason'] = module.params.get('revoke_reason')
        # Determine endpoint
        if module.params.get('acme_version') == 1:
            endpoint = account.directory['revoke-cert']
            payload['resource'] = 'revoke-cert'
        else:
            endpoint = account.directory['revokeCert']
        # Get hold of private key (if available) and make sure it comes from disk
        private_key = module.params.get('private_key_src')
        private_key_content = module.params.get('private_key_content')
        # Revoke certificate
        if private_key or private_key_content:
            # Step 1: load and parse private key
            error, private_key_data = account.parse_key(
                private_key, private_key_content)
            if error:
                raise ModuleFailException(
                    "error while parsing private key: %s" % error)
            # Step 2: sign revokation request with private key
            jws_header = {
                "alg": private_key_data['alg'],
                "jwk": private_key_data['jwk'],
            }
            result, info = account.send_signed_request(
                endpoint,
                payload,
                key_data=private_key_data,
                jws_header=jws_header)
        else:
            # Step 1: get hold of account URI
            changed = account.init_account(
                [],
                allow_creation=False,
                update_contact=False,
            )
            if changed:
                raise AssertionError('Unwanted account change')
            # Step 2: sign revokation request with account key
            result, info = account.send_signed_request(endpoint, payload)
        if info['status'] != 200:
            already_revoked = False
            # Standarized error in draft 14 (https://tools.ietf.org/html/draft-ietf-acme-acme-14#section-7.6)
            if result.get(
                    'type') == 'urn:ietf:params:acme:error:alreadyRevoked':
                already_revoked = True
            else:
                # Hack for Boulder errors
                if module.params.get('acme_version') == 1:
                    error_type = 'urn:acme:error:malformed'
                else:
                    error_type = 'urn:ietf:params:acme:error:malformed'
                if result.get('type') == error_type and result.get(
                        'detail') == 'Certificate already revoked':
                    # Fallback: boulder returns this in case the certificate was already revoked.
                    already_revoked = True
            # If we know the certificate was already revoked, we don't fail,
            # but successfully terminate while indicating no change
            if already_revoked:
                module.exit_json(changed=False)
            raise ModuleFailException(
                'Error revoking certificate: {0} {1}'.format(
                    info['status'], result))
        module.exit_json(changed=True)
    except ModuleFailException as e:
        e.do_fail(module)
Beispiel #7
0
def main():
    module = AnsibleModule(
        argument_spec=dict(
            account_key_src=dict(type='path', aliases=['account_key']),
            account_key_content=dict(type='str', no_log=True),
            acme_directory=dict(
                required=False,
                default='https://acme-staging.api.letsencrypt.org/directory',
                type='str'),
            acme_version=dict(required=False,
                              default=1,
                              choices=[1, 2],
                              type='int'),
            validate_certs=dict(required=False, default=True, type='bool'),
            terms_agreed=dict(required=False, default=False, type='bool'),
            state=dict(required=True,
                       choices=['absent', 'present', 'changed_key'],
                       type='str'),
            allow_creation=dict(required=False, default=True, type='bool'),
            contact=dict(required=False, type='list', default=[]),
            new_account_key_src=dict(type='path'),
            new_account_key_content=dict(type='str', no_log=True),
            select_crypto_backend=dict(
                required=False,
                choices=['auto', 'openssl', 'cryptography'],
                default='auto',
                type='str'),
        ),
        required_one_of=(['account_key_src', 'account_key_content'], ),
        mutually_exclusive=(
            ['account_key_src', 'account_key_content'],
            ['new_account_key_src', 'new_account_key_content'],
        ),
        required_if=(
            # Make sure that for state == changed_key, one of
            # new_account_key_src and new_account_key_content are specified
            [
                'state', 'changed_key',
                ['new_account_key_src', 'new_account_key_content'], True
            ], ),
        supports_check_mode=True,
    )
    set_crypto_backend(module)

    if not module.params.get('validate_certs'):
        module.warn(
            warning=
            'Disabling certificate validation for communications with ACME endpoint. '
            +
            'This should only be done for testing against a local ACME server for '
            + 'development purposes, but *never* for production purposes.')
    if module.params.get('acme_version') < 2:
        module.fail_json(
            msg='The acme_account module requires the ACME v2 protocol!')

    try:
        account = ACMEAccount(module)
        state = module.params.get('state')
        if state == 'absent':
            changed = account.init_account(
                [],
                allow_creation=False,
                update_contact=False,
            )
            if changed:
                raise AssertionError('Unwanted account change')
            if account.uri is not None:
                # Account does exist
                account_data = account.get_account_data()
                if account_data is not None:
                    # Account is not yet deactivated
                    if not module.check_mode:
                        # Deactivate it
                        payload = {'status': 'deactivated'}
                        result, info = account.send_signed_request(
                            account.uri, payload)
                        if info['status'] != 200:
                            raise ModuleFailException(
                                'Error deactivating account: {0} {1}'.format(
                                    info['status'], result))
                    module.exit_json(changed=True, account_uri=account.uri)
            module.exit_json(changed=False, account_uri=account.uri)
        elif state == 'present':
            allow_creation = module.params.get('allow_creation')
            contact = module.params.get('contact')
            terms_agreed = module.params.get('terms_agreed')
            changed = account.init_account(
                contact,
                terms_agreed=terms_agreed,
                allow_creation=allow_creation,
            )
            if account.uri is None:
                raise ModuleFailException(
                    msg='Account does not exist or is deactivated.')
            module.exit_json(changed=changed, account_uri=account.uri)
        elif state == 'changed_key':
            # Parse new account key
            error, new_key_data = account.parse_key(
                module.params.get('new_account_key_src'),
                module.params.get('new_account_key_content'))
            if error:
                raise ModuleFailException(
                    "error while parsing account key: %s" % error)
            # Verify that the account exists and has not been deactivated
            changed = account.init_account(
                [],
                allow_creation=False,
                update_contact=False,
            )
            if changed:
                raise AssertionError('Unwanted account change')
            if account.uri is None or account.get_account_data() is None:
                raise ModuleFailException(
                    msg='Account does not exist or is deactivated.')
            # Now we can start the account key rollover
            if not module.check_mode:
                # Compose inner signed message
                # https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.3.6
                url = account.directory['keyChange']
                protected = {
                    "alg": new_key_data['alg'],
                    "jwk": new_key_data['jwk'],
                    "url": url,
                }
                payload = {
                    "account": account.uri,
                    "newKey": new_key_data['jwk'],  # specified in draft 12
                    "oldKey": account.
                    jwk,  # discussed in https://github.com/ietf-wg-acme/acme/pull/425,
                    # might be required in draft 13
                }
                data = account.sign_request(protected, payload, new_key_data)
                # Send request and verify result
                result, info = account.send_signed_request(url, data)
                if info['status'] != 200:
                    raise ModuleFailException(
                        'Error account key rollover: {0} {1}'.format(
                            info['status'], result))
            module.exit_json(changed=True, account_uri=account.uri)
    except ModuleFailException as e:
        e.do_fail(module)
Beispiel #8
0
def main():
    module = AnsibleModule(
        argument_spec=dict(
            account_key_src=dict(type='path', aliases=['account_key']),
            account_key_content=dict(type='str', no_log=True),
            account_uri=dict(required=False, type='str'),
            acme_directory=dict(
                required=False,
                default='https://acme-staging.api.letsencrypt.org/directory',
                type='str'),
            acme_version=dict(required=False,
                              default=1,
                              choices=[1, 2],
                              type='int'),
            validate_certs=dict(required=False, default=True, type='bool'),
            url=dict(required=False, type='str'),
            method=dict(required=False,
                        type='str',
                        choices=['get', 'post', 'directory-only'],
                        default='get'),
            content=dict(required=False, type='str'),
            fail_on_acme_error=dict(required=False, type='bool', default=True),
            select_crypto_backend=dict(
                required=False,
                choices=['auto', 'openssl', 'cryptography'],
                default='auto',
                type='str'),
        ),
        mutually_exclusive=(['account_key_src', 'account_key_content'], ),
        required_if=(
            ['method', 'get', ['url']],
            ['method', 'post', ['url', 'content']],
            [
                'method', 'get', ['account_key_src', 'account_key_content'],
                True
            ],
            [
                'method', 'post', ['account_key_src', 'account_key_content'],
                True
            ],
        ),
    )
    set_crypto_backend(module)

    if not module.params.get('validate_certs'):
        module.warn(
            warning=
            'Disabling certificate validation for communications with ACME endpoint. '
            +
            'This should only be done for testing against a local ACME server for '
            + 'development purposes, but *never* for production purposes.')

    result = dict()
    changed = False
    try:
        # Get hold of ACMEAccount object (includes directory)
        account = ACMEAccount(module)
        method = module.params['method']
        result['directory'] = account.directory.directory
        # Do we have to do more requests?
        if method != 'directory-only':
            url = module.params['url']
            fail_on_acme_error = module.params['fail_on_acme_error']
            # Do request
            if method == 'get':
                data, info = account.get_request(url,
                                                 parse_json_result=False,
                                                 fail_on_error=False)
            elif method == 'post':
                changed = True  # only POSTs can change
                data, info = account.send_signed_request(
                    url,
                    to_bytes(module.params['content']),
                    parse_json_result=False,
                    encode_payload=False)
            # Update results
            result.update(dict(
                headers=info,
                output_text=to_native(data),
            ))
            # See if we can parse the result as JSON
            try:
                result['output_json'] = json.loads(data)
            except Exception as dummy:
                pass
            # Fail if error was returned
            if fail_on_acme_error and info['status'] >= 400:
                raise ModuleFailException(
                    "ACME request failed: CODE: {0} RESULT: {1}".format(
                        info['status'], data))
        # Done!
        module.exit_json(changed=changed, **result)
    except ModuleFailException as e:
        e.do_fail(module, **result)
Beispiel #9
0
def main():
    argument_spec = get_default_argspec()
    argument_spec.update(
        dict(
            terms_agreed=dict(type='bool', default=False),
            state=dict(type='str',
                       required=True,
                       choices=['absent', 'present', 'changed_key']),
            allow_creation=dict(type='bool', default=True),
            contact=dict(type='list', elements='str', default=[]),
            new_account_key_src=dict(type='path'),
            new_account_key_content=dict(type='str', no_log=True),
        ))
    module = AnsibleModule(
        argument_spec=argument_spec,
        required_one_of=(['account_key_src', 'account_key_content'], ),
        mutually_exclusive=(
            ['account_key_src', 'account_key_content'],
            ['new_account_key_src', 'new_account_key_content'],
        ),
        required_if=(
            # Make sure that for state == changed_key, one of
            # new_account_key_src and new_account_key_content are specified
            [
                'state', 'changed_key',
                ['new_account_key_src', 'new_account_key_content'], True
            ], ),
        supports_check_mode=True,
    )
    handle_standard_module_arguments(module, needs_acme_v2=True)

    try:
        account = ACMEAccount(module)
        changed = False
        state = module.params.get('state')
        diff_before = {}
        diff_after = {}
        if state == 'absent':
            created, account_data = account.setup_account(allow_creation=False)
            if account_data:
                diff_before = dict(account_data)
                diff_before['public_account_key'] = account.key_data['jwk']
            if created:
                raise AssertionError('Unwanted account creation')
            if account_data is not None:
                # Account is not yet deactivated
                if not module.check_mode:
                    # Deactivate it
                    payload = {'status': 'deactivated'}
                    result, info = account.send_signed_request(
                        account.uri, payload)
                    if info['status'] != 200:
                        raise ModuleFailException(
                            'Error deactivating account: {0} {1}'.format(
                                info['status'], result))
                changed = True
        elif state == 'present':
            allow_creation = module.params.get('allow_creation')
            # Make sure contact is a list of strings (unfortunately, Ansible doesn't do that for us)
            contact = [str(v) for v in module.params.get('contact')]
            terms_agreed = module.params.get('terms_agreed')
            created, account_data = account.setup_account(
                contact,
                terms_agreed=terms_agreed,
                allow_creation=allow_creation,
            )
            if account_data is None:
                raise ModuleFailException(
                    msg='Account does not exist or is deactivated.')
            if created:
                diff_before = {}
            else:
                diff_before = dict(account_data)
                diff_before['public_account_key'] = account.key_data['jwk']
            updated = False
            if not created:
                updated, account_data = account.update_account(
                    account_data, contact)
            changed = created or updated
            diff_after = dict(account_data)
            diff_after['public_account_key'] = account.key_data['jwk']
        elif state == 'changed_key':
            # Parse new account key
            error, new_key_data = account.parse_key(
                module.params.get('new_account_key_src'),
                module.params.get('new_account_key_content'))
            if error:
                raise ModuleFailException(
                    "error while parsing account key: %s" % error)
            # Verify that the account exists and has not been deactivated
            created, account_data = account.setup_account(allow_creation=False)
            if created:
                raise AssertionError('Unwanted account creation')
            if account_data is None:
                raise ModuleFailException(
                    msg='Account does not exist or is deactivated.')
            diff_before = dict(account_data)
            diff_before['public_account_key'] = account.key_data['jwk']
            # Now we can start the account key rollover
            if not module.check_mode:
                # Compose inner signed message
                # https://tools.ietf.org/html/rfc8555#section-7.3.5
                url = account.directory['keyChange']
                protected = {
                    "alg": new_key_data['alg'],
                    "jwk": new_key_data['jwk'],
                    "url": url,
                }
                payload = {
                    "account": account.uri,
                    "newKey":
                    new_key_data['jwk'],  # specified in draft 12 and older
                    "oldKey": account.jwk,  # specified in draft 13 and newer
                }
                data = account.sign_request(protected, payload, new_key_data)
                # Send request and verify result
                result, info = account.send_signed_request(url, data)
                if info['status'] != 200:
                    raise ModuleFailException(
                        'Error account key rollover: {0} {1}'.format(
                            info['status'], result))
                if module._diff:
                    account.key_data = new_key_data
                    account.jws_header['alg'] = new_key_data['alg']
                    diff_after = account.get_account_data()
            elif module._diff:
                # Kind of fake diff_after
                diff_after = dict(diff_before)
            diff_after['public_account_key'] = new_key_data['jwk']
            changed = True
        result = {
            'changed': changed,
            'account_uri': account.uri,
        }
        if module._diff:
            result['diff'] = {
                'before': diff_before,
                'after': diff_after,
            }
        module.exit_json(**result)
    except ModuleFailException as e:
        e.do_fail(module)
Beispiel #10
0
def main():
    module = AnsibleModule(
        argument_spec=dict(
            account_key_src=dict(type='path', aliases=['account_key']),
            account_key_content=dict(type='str', no_log=True),
            account_uri=dict(required=False, type='str'),
            acme_directory=dict(
                required=False,
                default='https://acme-staging.api.letsencrypt.org/directory',
                type='str'),
            acme_version=dict(required=False,
                              default=1,
                              choices=[1, 2],
                              type='int'),
            validate_certs=dict(required=False, default=True, type='bool'),
            terms_agreed=dict(required=False, default=False, type='bool'),
            state=dict(required=True,
                       choices=['absent', 'present', 'changed_key'],
                       type='str'),
            allow_creation=dict(required=False, default=True, type='bool'),
            contact=dict(required=False,
                         type='list',
                         elements='str',
                         default=[]),
            new_account_key_src=dict(type='path'),
            new_account_key_content=dict(type='str', no_log=True),
            select_crypto_backend=dict(
                required=False,
                choices=['auto', 'openssl', 'cryptography'],
                default='auto',
                type='str'),
        ),
        required_one_of=(['account_key_src', 'account_key_content'], ),
        mutually_exclusive=(
            ['account_key_src', 'account_key_content'],
            ['new_account_key_src', 'new_account_key_content'],
        ),
        required_if=(
            # Make sure that for state == changed_key, one of
            # new_account_key_src and new_account_key_content are specified
            [
                'state', 'changed_key',
                ['new_account_key_src', 'new_account_key_content'], True
            ], ),
        supports_check_mode=True,
    )
    set_crypto_backend(module)

    if not module.params.get('validate_certs'):
        module.warn(
            warning=
            'Disabling certificate validation for communications with ACME endpoint. '
            +
            'This should only be done for testing against a local ACME server for '
            + 'development purposes, but *never* for production purposes.')
    if module.params.get('acme_version') < 2:
        module.fail_json(
            msg='The acme_account module requires the ACME v2 protocol!')

    try:
        account = ACMEAccount(module)
        changed = False
        state = module.params.get('state')
        diff_before = {}
        diff_after = {}
        if state == 'absent':
            created, account_data = account.setup_account(allow_creation=False)
            if account_data:
                diff_before = dict(account_data)
                diff_before['public_account_key'] = account.key_data['jwk']
            if created:
                raise AssertionError('Unwanted account creation')
            if account_data is not None:
                # Account is not yet deactivated
                if not module.check_mode:
                    # Deactivate it
                    payload = {'status': 'deactivated'}
                    result, info = account.send_signed_request(
                        account.uri, payload)
                    if info['status'] != 200:
                        raise ModuleFailException(
                            'Error deactivating account: {0} {1}'.format(
                                info['status'], result))
                changed = True
        elif state == 'present':
            allow_creation = module.params.get('allow_creation')
            # Make sure contact is a list of strings (unfortunately, Ansible doesn't do that for us)
            contact = [str(v) for v in module.params.get('contact')]
            terms_agreed = module.params.get('terms_agreed')
            created, account_data = account.setup_account(
                contact,
                terms_agreed=terms_agreed,
                allow_creation=allow_creation,
            )
            if account_data is None:
                raise ModuleFailException(
                    msg='Account does not exist or is deactivated.')
            if created:
                diff_before = {}
            else:
                diff_before = dict(account_data)
                diff_before['public_account_key'] = account.key_data['jwk']
            updated = False
            if not created:
                updated, account_data = account.update_account(
                    account_data, contact)
            changed = created or updated
            diff_after = dict(account_data)
            diff_after['public_account_key'] = account.key_data['jwk']
        elif state == 'changed_key':
            # Parse new account key
            error, new_key_data = account.parse_key(
                module.params.get('new_account_key_src'),
                module.params.get('new_account_key_content'))
            if error:
                raise ModuleFailException(
                    "error while parsing account key: %s" % error)
            # Verify that the account exists and has not been deactivated
            created, account_data = account.setup_account(allow_creation=False)
            if created:
                raise AssertionError('Unwanted account creation')
            if account_data is None:
                raise ModuleFailException(
                    msg='Account does not exist or is deactivated.')
            diff_before = dict(account_data)
            diff_before['public_account_key'] = account.key_data['jwk']
            # Now we can start the account key rollover
            if not module.check_mode:
                # Compose inner signed message
                # https://tools.ietf.org/html/draft-ietf-acme-acme-14#section-7.3.6
                url = account.directory['keyChange']
                protected = {
                    "alg": new_key_data['alg'],
                    "jwk": new_key_data['jwk'],
                    "url": url,
                }
                payload = {
                    "account": account.uri,
                    "newKey":
                    new_key_data['jwk'],  # specified in draft 12 and older
                    "oldKey": account.jwk,  # specified in draft 13 and newer
                }
                data = account.sign_request(protected, payload, new_key_data)
                # Send request and verify result
                result, info = account.send_signed_request(url, data)
                if info['status'] != 200:
                    raise ModuleFailException(
                        'Error account key rollover: {0} {1}'.format(
                            info['status'], result))
                if module._diff:
                    account.key_data = new_key_data
                    account.jws_header['alg'] = new_key_data['alg']
                    diff_after = account.get_account_data()
            elif module._diff:
                # Kind of fake diff_after
                diff_after = dict(diff_before)
            diff_after['public_account_key'] = new_key_data['jwk']
            changed = True
        result = {
            'changed': changed,
            'account_uri': account.uri,
        }
        if module._diff:
            result['diff'] = {
                'before': diff_before,
                'after': diff_after,
            }
        module.exit_json(**result)
    except ModuleFailException as e:
        e.do_fail(module)
Beispiel #11
0
def main():
    argument_spec = get_default_argspec()
    argument_spec.update(dict(
        private_key_src=dict(type='path'),
        private_key_content=dict(type='str', no_log=True),
        certificate=dict(type='path', required=True),
        revoke_reason=dict(type='int'),
    ))
    module = AnsibleModule(
        argument_spec=argument_spec,
        required_one_of=(
            ['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'],
        ),
        mutually_exclusive=(
            ['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'],
        ),
        supports_check_mode=False,
    )
    handle_standard_module_arguments(module)

    try:
        account = ACMEAccount(module)
        # Load certificate
        certificate = pem_to_der(module.params.get('certificate'))
        certificate = nopad_b64(certificate)
        # Construct payload
        payload = {
            'certificate': certificate
        }
        if module.params.get('revoke_reason') is not None:
            payload['reason'] = module.params.get('revoke_reason')
        # Determine endpoint
        if module.params.get('acme_version') == 1:
            endpoint = account.directory['revoke-cert']
            payload['resource'] = 'revoke-cert'
        else:
            endpoint = account.directory['revokeCert']
        # Get hold of private key (if available) and make sure it comes from disk
        private_key = module.params.get('private_key_src')
        private_key_content = module.params.get('private_key_content')
        # Revoke certificate
        if private_key or private_key_content:
            # Step 1: load and parse private key
            error, private_key_data = account.parse_key(private_key, private_key_content)
            if error:
                raise ModuleFailException("error while parsing private key: %s" % error)
            # Step 2: sign revokation request with private key
            jws_header = {
                "alg": private_key_data['alg'],
                "jwk": private_key_data['jwk'],
            }
            result, info = account.send_signed_request(endpoint, payload, key_data=private_key_data, jws_header=jws_header)
        else:
            # Step 1: get hold of account URI
            created, account_data = account.setup_account(allow_creation=False)
            if created:
                raise AssertionError('Unwanted account creation')
            if account_data is None:
                raise ModuleFailException(msg='Account does not exist or is deactivated.')
            # Step 2: sign revokation request with account key
            result, info = account.send_signed_request(endpoint, payload)
        if info['status'] != 200:
            already_revoked = False
            # Standardized error from draft 14 on (https://tools.ietf.org/html/rfc8555#section-7.6)
            if result.get('type') == 'urn:ietf:params:acme:error:alreadyRevoked':
                already_revoked = True
            else:
                # Hack for Boulder errors
                if module.params.get('acme_version') == 1:
                    error_type = 'urn:acme:error:malformed'
                else:
                    error_type = 'urn:ietf:params:acme:error:malformed'
                if result.get('type') == error_type and result.get('detail') == 'Certificate already revoked':
                    # Fallback: boulder returns this in case the certificate was already revoked.
                    already_revoked = True
            # If we know the certificate was already revoked, we don't fail,
            # but successfully terminate while indicating no change
            if already_revoked:
                module.exit_json(changed=False)
            raise ModuleFailException('Error revoking certificate: {0} {1}'.format(info['status'], result))
        module.exit_json(changed=True)
    except ModuleFailException as e:
        e.do_fail(module)
def main():
    module = AnsibleModule(
        argument_spec=dict(
            account_key_src=dict(type='path', aliases=['account_key']),
            account_key_content=dict(type='str', no_log=True),
            acme_directory=dict(
                required=False,
                default='https://acme-staging.api.letsencrypt.org/directory',
                type='str'),
            acme_version=dict(required=False,
                              default=1,
                              choices=[1, 2],
                              type='int'),
            validate_certs=dict(required=False, default=True, type='bool'),
            private_key_src=dict(type='path'),
            private_key_content=dict(type='str', no_log=True),
            certificate=dict(required=True, type='path'),
            revoke_reason=dict(required=False, type='int'),
        ),
        required_one_of=([
            'account_key_src', 'account_key_content', 'private_key_src',
            'private_key_content'
        ], ),
        mutually_exclusive=([
            'account_key_src', 'account_key_content', 'private_key_src',
            'private_key_content'
        ], ),
        supports_check_mode=False,
    )

    if not module.params.get('validate_certs'):
        module.warn(
            warning=
            'Disabling certificate validation for communications with ACME endpoint. '
            +
            'This should only be done for testing against a local ACME server for '
            + 'development purposes, but *never* for production purposes.')

    try:
        account = ACMEAccount(module)
        # Load certificate
        certificate_lines = []
        try:
            with open(module.params.get('certificate'), "rt") as f:
                header_line_count = 0
                for line in f:
                    if line.startswith('-----'):
                        header_line_count += 1
                        if header_line_count == 2:
                            # If certificate file contains other certs appended
                            # (like intermediate certificates), ignore these.
                            break
                        continue
                    certificate_lines.append(line.strip())
        except Exception as err:
            raise ModuleFailException("cannot load certificate file: %s" %
                                      to_native(err),
                                      exception=traceback.format_exc())
        certificate = nopad_b64(base64.b64decode(''.join(certificate_lines)))
        # Construct payload
        payload = {'certificate': certificate}
        if module.params.get('revoke_reason') is not None:
            payload['reason'] = module.params.get('revoke_reason')
        # Determine endpoint
        if module.params.get('acme_version') == 1:
            endpoint = account.directory['revoke-cert']
            payload['resource'] = 'revoke-cert'
        else:
            endpoint = account.directory['revokeCert']
        # Get hold of private key (if available) and make sure it comes from disk
        private_key = module.params.get('private_key_src')
        if module.params.get('private_key_content') is not None:
            fd, tmpsrc = tempfile.mkstemp()
            module.add_cleanup_file(
                tmpsrc)  # Ansible will delete the file on exit
            f = os.fdopen(fd, 'wb')
            try:
                f.write(
                    module.params.get('private_key_content').encode('utf-8'))
                private_key = tmpsrc
            except Exception as err:
                try:
                    f.close()
                except Exception as e:
                    pass
                raise ModuleFailException(
                    "failed to create temporary content file: %s" %
                    to_native(err),
                    exception=traceback.format_exc())
            f.close()
        # Revoke certificate
        if private_key:
            # Step 1: load and parse private key
            error, private_key_data = account.parse_account_key(private_key)
            if error:
                raise ModuleFailException(
                    "error while parsing private key: %s" % error)
            # Step 2: sign revokation request with private key
            jws_header = {
                "alg": private_key_data['alg'],
                "jwk": private_key_data['jwk'],
            }
            result, info = account.send_signed_request(
                endpoint,
                payload,
                key=private_key,
                key_data=private_key_data,
                jws_header=jws_header)
        else:
            # Step 1: get hold of account URI
            changed = account.init_account(
                [],
                allow_creation=False,
                update_contact=False,
            )
            if changed:
                raise AssertionError('Unwanted account change')
            # Step 2: sign revokation request with account key
            result, info = account.send_signed_request(endpoint, payload)
        if info['status'] != 200:
            if module.params.get('acme_version') == 1:
                error_type = 'urn:acme:error:malformed'
            else:
                error_type = 'urn:ietf:params:acme:error:malformed'
            if result.get('type') == error_type and result.get(
                    'detail') == 'Certificate already revoked':
                # Fallback: boulder returns this in case the certificate was already revoked.
                module.exit_json(changed=False)
            raise ModuleFailException(
                'Error revoking certificate: {0} {1}'.format(
                    info['status'], result))
        module.exit_json(changed=True)
    except ModuleFailException as e:
        e.do_fail(module)