mirror of
https://github.com/ansible-collections/community.general.git
synced 2025-06-21 07:40:22 -07:00
Move acme_* modules from web_infrastructure into crypto category (#44279)
* Moving acme_* modules from web_infrastructure into crypto category. * Creating new subcategory 'acme' in 'crypto'.
This commit is contained in:
parent
aa354cbad0
commit
f7ac1c588d
6 changed files with 1 additions and 4 deletions
0
lib/ansible/modules/crypto/acme/__init__.py
Normal file
0
lib/ansible/modules/crypto/acme/__init__.py
Normal file
1
lib/ansible/modules/crypto/acme/_letsencrypt.py
Symbolic link
1
lib/ansible/modules/crypto/acme/_letsencrypt.py
Symbolic link
|
@ -0,0 +1 @@
|
|||
acme_certificate.py
|
247
lib/ansible/modules/crypto/acme/acme_account.py
Normal file
247
lib/ansible/modules/crypto/acme/acme_account.py
Normal file
|
@ -0,0 +1,247 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# 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: acme_account
|
||||
author: "Felix Fontein (@felixfontein)"
|
||||
version_added: "2.6"
|
||||
short_description: Create, modify or delete accounts with Let's Encrypt
|
||||
description:
|
||||
- "Allows to create, modify or delete accounts with Let's Encrypt.
|
||||
Let's Encrypt is a free, automated, and open certificate authority
|
||||
(CA), run for the public's benefit. For details see U(https://letsencrypt.org)."
|
||||
- "The M(acme_certificate) module also allows to do basic account management.
|
||||
When using both modules, it is recommended to disable account management
|
||||
for M(acme_certificate). For that, use the C(modify_account) option of
|
||||
M(acme_certificate)."
|
||||
- "This module only works with the ACME v2 protocol."
|
||||
extends_documentation_fragment:
|
||||
- acme
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- "The state of the account, to be identified by its account key."
|
||||
- "If the state is C(absent), the account will either not exist or be
|
||||
deactivated."
|
||||
- "If the state is C(changed_key), the account must exist. The account
|
||||
key will be changed; no other information will be touched."
|
||||
required: true
|
||||
choices:
|
||||
- present
|
||||
- absent
|
||||
- changed_key
|
||||
allow_creation:
|
||||
description:
|
||||
- "Whether account creation is allowed (when state is C(present))."
|
||||
default: yes
|
||||
type: bool
|
||||
contact:
|
||||
description:
|
||||
- "A list of contact URLs."
|
||||
- "Email addresses must be prefixed with C(mailto:)."
|
||||
- "See https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.1.2
|
||||
for what is allowed."
|
||||
- "Must be specified when state is C(present). Will be ignored
|
||||
if state is C(absent) or C(changed_key)."
|
||||
default: []
|
||||
terms_agreed:
|
||||
description:
|
||||
- "Boolean indicating whether you agree to the terms of service document."
|
||||
- "ACME servers can require this to be true."
|
||||
default: no
|
||||
type: bool
|
||||
new_account_key_src:
|
||||
description:
|
||||
- "Path to a file containing the Let's Encrypt account RSA or Elliptic Curve
|
||||
key to change to."
|
||||
- "Same restrictions apply as to C(account_key_src)."
|
||||
- "Mutually exclusive with C(new_account_key_content)."
|
||||
- "Required if C(new_account_key_content) is not used and state is C(changed_key)."
|
||||
new_account_key_content:
|
||||
description:
|
||||
- "Content of the Let's Encrypt account RSA or Elliptic Curve key to change to."
|
||||
- "Same restrictions apply as to C(account_key_content)."
|
||||
- "Mutually exclusive with C(new_account_key_src)."
|
||||
- "Required if C(new_account_key_src) is not used and state is C(changed_key)."
|
||||
'''
|
||||
|
||||
EXAMPLES = '''
|
||||
- name: Make sure account exists and has given contacts. We agree to TOS.
|
||||
acme_account:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
state: present
|
||||
terms_agreed: yes
|
||||
contact:
|
||||
- mailto:me@example.com
|
||||
- mailto:myself@example.org
|
||||
|
||||
- name: Make sure account has given email address. Don't create account if it doesn't exist
|
||||
acme_account:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
state: present
|
||||
allow_creation: no
|
||||
contact:
|
||||
- mailto:me@example.com
|
||||
|
||||
- name: Change account's key to the one stored in the variable new_account_key
|
||||
acme_account:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
new_account_key_content: '{{ new_account_key }}'
|
||||
state: changed_key
|
||||
|
||||
- name: Delete account (we have to use the new key)
|
||||
acme_account:
|
||||
account_key_content: '{{ new_account_key }}'
|
||||
state: absent
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
account_uri:
|
||||
description: ACME account URI, or None if account does not exist.
|
||||
returned: always
|
||||
type: string
|
||||
'''
|
||||
|
||||
from ansible.module_utils.acme import (
|
||||
ModuleFailException, ACMEAccount, set_crypto_backend,
|
||||
)
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
957
lib/ansible/modules/crypto/acme/acme_certificate.py
Normal file
957
lib/ansible/modules/crypto/acme/acme_certificate.py
Normal file
|
@ -0,0 +1,957 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# 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: acme_certificate
|
||||
author: "Michael Gruener (@mgruener)"
|
||||
version_added: "2.2"
|
||||
short_description: Create SSL certificates with an ACME protocol endpoint
|
||||
description:
|
||||
- "Create and renew SSL certificates with a CA supporting the
|
||||
L(ACME protocol,https://tools.ietf.org/html/draft-ietf-acme-acme-12),
|
||||
such as L(Let's Encrypt,https://letsencrypt.org/). The current
|
||||
implementation supports the C(http-01), C(dns-01) and C(tls-alpn-01)
|
||||
challenges."
|
||||
- "To use this module, it has to be executed twice. Either as two
|
||||
different tasks in the same run or during two runs. Note that the output
|
||||
of the first run needs to be recorded and passed to the second run as the
|
||||
module argument C(data)."
|
||||
- "Between these two tasks you have to fulfill the required steps for the
|
||||
chosen challenge by whatever means necessary. For C(http-01) that means
|
||||
creating the necessary challenge file on the destination webserver. For
|
||||
C(dns-01) the necessary dns record has to be created. For C(tls-alpn-01)
|
||||
the necessary certificate has to be created and served.
|
||||
It is I(not) the responsibility of this module to perform these steps."
|
||||
- "For details on how to fulfill these challenges, you might have to read through
|
||||
L(the main ACME specification,https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-8)
|
||||
and the L(TLS-ALPN-01 specification,U(https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01#section-3).
|
||||
Also, consider the examples provided for this module."
|
||||
- "Although the defaults are chosen so that the module can be used with
|
||||
the Let's Encrypt CA, the module can be used with any service using the ACME
|
||||
v1 or v2 protocol."
|
||||
- "At least one of C(dest) and C(fullchain_dest) must be specified."
|
||||
- "Note that this module includes basic account management functionality.
|
||||
If you want to have more control over your ACME account, use the M(acme_account)
|
||||
module and disable account management for this module using the C(modify_account)
|
||||
option."
|
||||
- "Note: this module was called C(letsencrypt) before Ansible 2.6. The usage
|
||||
did not change."
|
||||
extends_documentation_fragment:
|
||||
- acme
|
||||
options:
|
||||
account_email:
|
||||
description:
|
||||
- "The email address associated with this account."
|
||||
- "It will be used for certificate expiration warnings."
|
||||
- "Note that when C(modify_account) is not set to C(no) and you also
|
||||
used the M(acme_account) module to specify more than one contact
|
||||
for your account, this module will update your account and restrict
|
||||
it to the (at most one) contact email address specified here."
|
||||
agreement:
|
||||
description:
|
||||
- "URI to a terms of service document you agree to when using the
|
||||
ACME v1 service at C(acme_directory)."
|
||||
- Default is latest gathered from C(acme_directory) URL.
|
||||
- This option will only be used when C(acme_version) is 1.
|
||||
terms_agreed:
|
||||
description:
|
||||
- "Boolean indicating whether you agree to the terms of service document."
|
||||
- "ACME servers can require this to be true."
|
||||
- This option will only be used when C(acme_version) is not 1.
|
||||
type: bool
|
||||
default: 'no'
|
||||
version_added: "2.5"
|
||||
modify_account:
|
||||
description:
|
||||
- "Boolean indicating whether the module should create the account if
|
||||
necessary, and update its contact data."
|
||||
- "Set to C(no) if you want to use the M(acme_account) module to manage
|
||||
your account instead, and to avoid accidental creation of a new account
|
||||
using an old key if you changed the account key with M(acme_account)."
|
||||
- "If set to C(no), C(terms_agreed) and C(account_email) are ignored."
|
||||
type: bool
|
||||
default: 'yes'
|
||||
version_added: "2.6"
|
||||
challenge:
|
||||
description: The challenge to be performed.
|
||||
choices: [ 'http-01', 'dns-01', 'tls-alpn-01' ]
|
||||
default: 'http-01'
|
||||
csr:
|
||||
description:
|
||||
- "File containing the CSR for the new certificate."
|
||||
- "Can be created with C(openssl req ...)."
|
||||
- "The CSR may contain multiple Subject Alternate Names, but each one
|
||||
will lead to an individual challenge that must be fulfilled for the
|
||||
CSR to be signed."
|
||||
- "I(Note): the private key used to create the CSR I(must not) be the the
|
||||
account key. This is a bad idea from a security point of view, and
|
||||
the CA should not accept the CSR. Let's Encrypt will return an error
|
||||
in this case."
|
||||
required: true
|
||||
aliases: ['src']
|
||||
data:
|
||||
description:
|
||||
- "The data to validate ongoing challenges. This must be specified for
|
||||
the second run of the module only."
|
||||
- "The value that must be used here will be provided by a previous use
|
||||
of this module. See the examples for more details."
|
||||
- "Note that for ACME v2, only the C(order_uri) entry of C(data) will
|
||||
be used. For ACME v1, C(data) must be non-empty to indicate the
|
||||
second stage is active; all needed data will be taken from the
|
||||
CSR."
|
||||
- "I(Note): the C(data) option was marked as C(no_log) up to
|
||||
Ansible 2.5. From Ansible 2.6 on, it is no longer marked this way
|
||||
as it causes error messages to be come unusable, and C(data) does
|
||||
not contain any information which can be used without having
|
||||
access to the account key or which are not public anyway."
|
||||
dest:
|
||||
description:
|
||||
- "The destination file for the certificate."
|
||||
- "Required if C(fullchain_dest) is not specified."
|
||||
aliases: ['cert']
|
||||
fullchain_dest:
|
||||
description:
|
||||
- "The destination file for the full chain (i.e. certificate followed
|
||||
by chain of intermediate certificates)."
|
||||
- "Required if C(dest) is not specified."
|
||||
version_added: 2.5
|
||||
aliases: ['fullchain']
|
||||
chain_dest:
|
||||
description:
|
||||
- If specified, the intermediate certificate will be written to this file.
|
||||
aliases: ['chain']
|
||||
version_added: 2.5
|
||||
remaining_days:
|
||||
description:
|
||||
- "The number of days the certificate must have left being valid.
|
||||
If C(cert_days < remaining_days), then it will be renewed.
|
||||
If the certificate is not renewed, module return values will not
|
||||
include C(challenge_data)."
|
||||
- "To make sure that the certificate is renewed in any case, you can
|
||||
use the C(force) option."
|
||||
default: 10
|
||||
deactivate_authzs:
|
||||
description:
|
||||
- "Deactivate authentication objects (authz) after issuing a certificate,
|
||||
or when issuing the certificate failed."
|
||||
- "Authentication objects are bound to an account key and remain valid
|
||||
for a certain amount of time, and can be used to issue certificates
|
||||
without having to re-authenticate the domain. This can be a security
|
||||
concern."
|
||||
type: bool
|
||||
default: 'no'
|
||||
version_added: 2.6
|
||||
force:
|
||||
description:
|
||||
- Enforces the execution of the challenge and validation, even if an
|
||||
existing certificate is still valid for more than C(remaining_days).
|
||||
- This is especially helpful when having an updated CSR e.g. with
|
||||
additional domains for which a new certificate is desired.
|
||||
type: bool
|
||||
default: 'no'
|
||||
version_added: 2.6
|
||||
'''
|
||||
|
||||
EXAMPLES = R'''
|
||||
### Example with HTTP challenge ###
|
||||
|
||||
- name: Create a challenge for sample.com using a account key from a variable.
|
||||
acme_certificate:
|
||||
account_key_content: "{{ account_private_key }}"
|
||||
csr: /etc/pki/cert/csr/sample.com.csr
|
||||
dest: /etc/httpd/ssl/sample.com.crt
|
||||
register: sample_com_challenge
|
||||
|
||||
# Alternative first step:
|
||||
- name: Create a challenge for sample.com using a account key from hashi vault.
|
||||
acme_certificate:
|
||||
account_key_content: "{{ lookup('hashi_vault', 'secret=secret/account_private_key:value') }}"
|
||||
csr: /etc/pki/cert/csr/sample.com.csr
|
||||
fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt
|
||||
register: sample_com_challenge
|
||||
|
||||
# Alternative first step:
|
||||
- name: Create a challenge for sample.com using a account key file.
|
||||
acme_certificate:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
csr: /etc/pki/cert/csr/sample.com.csr
|
||||
dest: /etc/httpd/ssl/sample.com.crt
|
||||
fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt
|
||||
register: sample_com_challenge
|
||||
|
||||
# perform the necessary steps to fulfill the challenge
|
||||
# for example:
|
||||
#
|
||||
# - copy:
|
||||
# dest: /var/www/html/{{ sample_com_challenge['challenge_data']['sample.com']['http-01']['resource'] }}
|
||||
# content: "{{ sample_com_challenge['challenge_data']['sample.com']['http-01']['resource_value'] }}"
|
||||
# when: sample_com_challenge is changed
|
||||
|
||||
- name: Let the challenge be validated and retrieve the cert and intermediate certificate
|
||||
acme_certificate:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
csr: /etc/pki/cert/csr/sample.com.csr
|
||||
dest: /etc/httpd/ssl/sample.com.crt
|
||||
fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt
|
||||
chain_dest: /etc/httpd/ssl/sample.com-intermediate.crt
|
||||
data: "{{ sample_com_challenge }}"
|
||||
|
||||
### Example with DNS challenge against production ACME server ###
|
||||
|
||||
- name: Create a challenge for sample.com using a account key file.
|
||||
acme_certificate:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
account_email: myself@sample.com
|
||||
src: /etc/pki/cert/csr/sample.com.csr
|
||||
cert: /etc/httpd/ssl/sample.com.crt
|
||||
challenge: dns-01
|
||||
acme_directory: https://acme-v01.api.letsencrypt.org/directory
|
||||
# Renew if the certificate is at least 30 days old
|
||||
remaining_days: 60
|
||||
register: sample_com_challenge
|
||||
|
||||
# perform the necessary steps to fulfill the challenge
|
||||
# for example:
|
||||
#
|
||||
# - route53:
|
||||
# zone: sample.com
|
||||
# record: "{{ sample_com_challenge.challenge_data['sample.com']['dns-01'].record }}"
|
||||
# type: TXT
|
||||
# ttl: 60
|
||||
# # Note: route53 requires TXT entries to be enclosed in quotes
|
||||
# value: "{{ sample_com_challenge.challenge_data['sample.com']['dns-01'].resource_value }}"
|
||||
# when: sample_com_challenge is changed
|
||||
#
|
||||
# Alternative way:
|
||||
#
|
||||
# - route53:
|
||||
# zone: sample.com
|
||||
# record: "{{ item.key }}"
|
||||
# type: TXT
|
||||
# ttl: 60
|
||||
# # Note: item.value is a list of TXT entries, and route53
|
||||
# # requires every entry to be enclosed in quotes
|
||||
# value: "{{ item.value | map('regex_replace', '^(.*)$', '\'\\1\'' ) | list }}"
|
||||
# with_dict: sample_com_challenge.challenge_data_dns
|
||||
# when: sample_com_challenge is changed
|
||||
|
||||
- name: Let the challenge be validated and retrieve the cert and intermediate certificate
|
||||
acme_certificate:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
account_email: myself@sample.com
|
||||
src: /etc/pki/cert/csr/sample.com.csr
|
||||
cert: /etc/httpd/ssl/sample.com.crt
|
||||
fullchain: /etc/httpd/ssl/sample.com-fullchain.crt
|
||||
chain: /etc/httpd/ssl/sample.com-intermediate.crt
|
||||
challenge: dns-01
|
||||
acme_directory: https://acme-v01.api.letsencrypt.org/directory
|
||||
remaining_days: 60
|
||||
data: "{{ sample_com_challenge }}"
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
cert_days:
|
||||
description: the number of days the certificate remains valid.
|
||||
returned: success
|
||||
type: int
|
||||
challenge_data:
|
||||
description: per domain / challenge type challenge data
|
||||
returned: changed
|
||||
type: complex
|
||||
contains:
|
||||
resource:
|
||||
description: the challenge resource that must be created for validation
|
||||
returned: changed
|
||||
type: string
|
||||
sample: .well-known/acme-challenge/evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA
|
||||
resource_value:
|
||||
description:
|
||||
- The value the resource has to produce for the validation.
|
||||
- For C(http-01) and C(dns-01) challenges, the value can be used as-is.
|
||||
- "For C(tls-alpn-01) challenges, note that this return value contains a
|
||||
Base64 encoded version of the correct binary blob which has to be put
|
||||
into the acmeValidation x509 extension; see
|
||||
U(https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01#section-3)
|
||||
for details. To do this, you might need the C(b64decode) Jinja filter
|
||||
to extract the binary blob from this return value."
|
||||
returned: changed
|
||||
type: string
|
||||
sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA
|
||||
record:
|
||||
description: the full DNS record's name for the challenge
|
||||
returned: changed and challenge is C(dns-01)
|
||||
type: string
|
||||
sample: _acme-challenge.example.com
|
||||
version_added: "2.5"
|
||||
challenge_data_dns:
|
||||
description: list of TXT values per DNS record, in case challenge is C(dns-01)
|
||||
returned: changed
|
||||
type: dict
|
||||
version_added: "2.5"
|
||||
authorizations:
|
||||
description: ACME authorization data.
|
||||
returned: changed
|
||||
type: complex
|
||||
contains:
|
||||
authorization:
|
||||
description: ACME authorization object. See U(https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.1.4)
|
||||
returned: success
|
||||
type: dict
|
||||
order_uri:
|
||||
description: ACME order URI.
|
||||
returned: changed
|
||||
type: string
|
||||
version_added: "2.5"
|
||||
finalization_uri:
|
||||
description: ACME finalization URI.
|
||||
returned: changed
|
||||
type: string
|
||||
version_added: "2.5"
|
||||
account_uri:
|
||||
description: ACME account URI.
|
||||
returned: changed
|
||||
type: string
|
||||
version_added: "2.5"
|
||||
'''
|
||||
|
||||
from ansible.module_utils.acme import (
|
||||
ModuleFailException, fetch_url, write_file, nopad_b64, simple_get, pem_to_der, ACMEAccount,
|
||||
HAS_CURRENT_CRYPTOGRAPHY, cryptography_get_csr_domains, cryptography_get_cert_days,
|
||||
set_crypto_backend,
|
||||
)
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import locale
|
||||
import os
|
||||
import re
|
||||
import textwrap
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible.module_utils._text import to_text, to_bytes
|
||||
|
||||
|
||||
def get_cert_days(module, cert_file):
|
||||
'''
|
||||
Return the days the certificate in cert_file remains valid and -1
|
||||
if the file was not found. If cert_file contains more than one
|
||||
certificate, only the first one will be considered.
|
||||
'''
|
||||
if HAS_CURRENT_CRYPTOGRAPHY:
|
||||
return cryptography_get_cert_days(module, cert_file)
|
||||
if not os.path.exists(cert_file):
|
||||
return -1
|
||||
|
||||
openssl_bin = module.get_bin_path('openssl', True)
|
||||
openssl_cert_cmd = [openssl_bin, "x509", "-in", cert_file, "-noout", "-text"]
|
||||
dummy, out, dummy = module.run_command(openssl_cert_cmd, check_rc=True, encoding=None)
|
||||
try:
|
||||
not_after_str = re.search(r"\s+Not After\s*:\s+(.*)", out.decode('utf8')).group(1)
|
||||
not_after = datetime.fromtimestamp(time.mktime(time.strptime(not_after_str, '%b %d %H:%M:%S %Y %Z')))
|
||||
except AttributeError:
|
||||
raise ModuleFailException("No 'Not after' date found in {0}".format(cert_file))
|
||||
except ValueError:
|
||||
raise ModuleFailException("Failed to parse 'Not after' date of {0}".format(cert_file))
|
||||
now = datetime.utcnow()
|
||||
return (not_after - now).days
|
||||
|
||||
|
||||
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/draft-ietf-acme-acme-12#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/draft-ietf-acme-acme-12#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-01#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 = simple_get(self.module, 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/draft-ietf-acme-acme-12#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 = simple_get(self.module, 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/draft-ietf-acme-acme-12#section-7.4.2
|
||||
'''
|
||||
resp, info = fetch_url(self.module, url, headers={'Accept': 'application/pem-certificate-chain'})
|
||||
try:
|
||||
content = resp.read()
|
||||
except AttributeError:
|
||||
content = info.get('body')
|
||||
|
||||
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 = fetch_url(self.module, chain_link, method='GET')
|
||||
if chain_info['status'] in [200, 201]:
|
||||
chain.append(self._der_to_pem(chain_result.read()))
|
||||
|
||||
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 = fetch_url(self.module, chain_link, method='GET')
|
||||
if chain_info['status'] in [200, 201]:
|
||||
chain = [self._der_to_pem(chain_result.read())]
|
||||
|
||||
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/draft-ietf-acme-acme-12#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 = simple_get(self.module, 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.
|
||||
resp, info = fetch_url(self.module, self.order_uri)
|
||||
try:
|
||||
result = resp.read()
|
||||
except AttributeError:
|
||||
result = info.get('body')
|
||||
|
||||
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))
|
||||
|
||||
result = self.module.from_json(result.decode('utf8'))
|
||||
for auth_uri in result['authorizations']:
|
||||
auth_data = simple_get(self.module, 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/draft-ietf-acme-acme-12#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']))
|
||||
|
||||
|
||||
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),
|
||||
modify_account=dict(required=False, type='bool', default=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'),
|
||||
account_email=dict(required=False, default=None, type='str'),
|
||||
agreement=dict(required=False, type='str'),
|
||||
terms_agreed=dict(required=False, default=False, type='bool'),
|
||||
challenge=dict(required=False, default='http-01', choices=['http-01', 'dns-01', 'tls-alpn-01'], type='str'),
|
||||
csr=dict(required=True, aliases=['src'], type='path'),
|
||||
data=dict(required=False, default=None, type='dict'),
|
||||
dest=dict(aliases=['cert'], type='path'),
|
||||
fullchain_dest=dict(aliases=['fullchain'], type='path'),
|
||||
chain_dest=dict(required=False, default=None, aliases=['chain'], type='path'),
|
||||
remaining_days=dict(required=False, default=10, type='int'),
|
||||
deactivate_authzs=dict(required=False, default=False, type='bool'),
|
||||
force=dict(required=False, default=False, type='bool'),
|
||||
select_crypto_backend=dict(required=False, choices=['auto', 'openssl', 'cryptography'], default='auto', type='str'),
|
||||
),
|
||||
required_one_of=(
|
||||
['account_key_src', 'account_key_content'],
|
||||
['dest', 'fullchain_dest'],
|
||||
),
|
||||
mutually_exclusive=(
|
||||
['account_key_src', 'account_key_content'],
|
||||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
if module._name == 'letsencrypt':
|
||||
module.deprecate("The 'letsencrypt' module is being renamed 'acme_certificate'", version='2.10')
|
||||
set_crypto_backend(module)
|
||||
|
||||
# AnsibleModule() changes the locale, so change it back to C because we rely on time.strptime() when parsing certificate dates.
|
||||
module.run_command_environ_update = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C')
|
||||
locale.setlocale(locale.LC_ALL, 'C')
|
||||
|
||||
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:
|
||||
if module.params.get('dest'):
|
||||
cert_days = get_cert_days(module, module.params['dest'])
|
||||
else:
|
||||
cert_days = get_cert_days(module, module.params['fullchain_dest'])
|
||||
|
||||
if module.params['force'] or cert_days < module.params['remaining_days']:
|
||||
# If checkmode is active, base the changed state solely on the status
|
||||
# of the certificate file as all other actions (accessing an account, checking
|
||||
# the authorization status...) would lead to potential changes of the current
|
||||
# state
|
||||
if module.check_mode:
|
||||
module.exit_json(changed=True, authorizations={}, challenge_data={}, cert_days=cert_days)
|
||||
else:
|
||||
client = ACMEClient(module)
|
||||
client.cert_days = cert_days
|
||||
if client.is_first_step():
|
||||
# First run: start challenges / start new order
|
||||
client.start_challenges()
|
||||
else:
|
||||
# Second run: finish challenges, and get certificate
|
||||
try:
|
||||
client.finish_challenges()
|
||||
client.get_certificate()
|
||||
finally:
|
||||
if module.params['deactivate_authzs']:
|
||||
client.deactivate_authzs()
|
||||
data, data_dns = client.get_challenges_data()
|
||||
module.exit_json(
|
||||
changed=client.changed,
|
||||
authorizations=client.authorizations,
|
||||
finalize_uri=client.finalize_uri,
|
||||
order_uri=client.order_uri,
|
||||
account_uri=client.account.uri,
|
||||
challenge_data=data,
|
||||
challenge_data_dns=data_dns,
|
||||
cert_days=client.cert_days
|
||||
)
|
||||
else:
|
||||
module.exit_json(changed=False, cert_days=cert_days)
|
||||
except ModuleFailException as e:
|
||||
e.do_fail(module)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
190
lib/ansible/modules/crypto/acme/acme_certificate_revoke.py
Normal file
190
lib/ansible/modules/crypto/acme/acme_certificate_revoke.py
Normal file
|
@ -0,0 +1,190 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# 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: acme_certificate_revoke
|
||||
author: "Felix Fontein (@felixfontein)"
|
||||
version_added: "2.7"
|
||||
short_description: Revoke certificates with the ACME protocol.
|
||||
description:
|
||||
- "Allows to revoke certificates with the ACME protocol, for example
|
||||
for certificates obtained by the M(acme_certificate) module. The
|
||||
ACME protocol is used by some Certificate Authorities such as
|
||||
L(Let's Encrypt,https://letsencrypt.org/)."
|
||||
- "Note that exactly one of C(account_key_src), C(account_key_content),
|
||||
C(private_key_src) or C(private_key_content) must be specified."
|
||||
- "Also note that trying to revoke an already revoked certificate
|
||||
should result in an unchanged status, even if the revocation reason
|
||||
was different than the one specified here. Also, depending on the
|
||||
server, it can happen that some other error is returned if the
|
||||
certificate has already been revoked."
|
||||
extends_documentation_fragment:
|
||||
- acme
|
||||
options:
|
||||
certificate:
|
||||
description:
|
||||
- "Path to the certificate to revoke."
|
||||
required: yes
|
||||
private_key_src:
|
||||
description:
|
||||
- "Path to the certificate's private key."
|
||||
- "Note that exactly one of C(account_key_src), C(account_key_content),
|
||||
C(private_key_src) or C(private_key_content) must be specified."
|
||||
private_key_content:
|
||||
description:
|
||||
- "Content of the certificate's private key."
|
||||
- "Note that exactly one of C(account_key_src), C(account_key_content),
|
||||
C(private_key_src) or C(private_key_content) must be specified."
|
||||
- "I(Warning): the content will be written into a temporary file, which will
|
||||
be deleted by Ansible when the module completes. Since this is an
|
||||
important private key — it can be used to change the account key,
|
||||
or to revoke your certificates without knowing their private keys
|
||||
—, this might not be acceptable."
|
||||
- "In case C(cryptography) is used, the content is not written into a
|
||||
temporary file. It can still happen that it is written to disk by
|
||||
Ansible in the process of moving the module with its argument to
|
||||
the node where it is executed."
|
||||
revoke_reason:
|
||||
description:
|
||||
- "One of the revocation reasonCodes defined in
|
||||
L(https://tools.ietf.org/html/rfc5280#section-5.3.1, Section 5.3.1 of RFC5280)."
|
||||
- "Possible values are C(0) (unspecified), C(1) (keyCompromise),
|
||||
C(2) (cACompromise), C(3) (affiliationChanged), C(4) (superseded),
|
||||
C(5) (cessationOfOperation), C(6) (certificateHold),
|
||||
C(8) (removeFromCRL), C(9) (privilegeWithdrawn),
|
||||
C(10) (aACompromise)"
|
||||
'''
|
||||
|
||||
EXAMPLES = '''
|
||||
- name: Revoke certificate with account key
|
||||
acme_certificate_revoke:
|
||||
account_key_src: /etc/pki/cert/private/account.key
|
||||
certificate: /etc/httpd/ssl/sample.com.crt
|
||||
|
||||
- name: Revoke certificate with certificate's private key
|
||||
acme_certificate_revoke:
|
||||
private_key_src: /etc/httpd/ssl/sample.com.key
|
||||
certificate: /etc/httpd/ssl/sample.com.crt
|
||||
'''
|
||||
|
||||
RETURN = '''
|
||||
'''
|
||||
|
||||
from ansible.module_utils.acme import (
|
||||
ModuleFailException, ACMEAccount, nopad_b64, pem_to_der, set_crypto_backend,
|
||||
)
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
|
||||
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'),
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
Loading…
Add table
Add a link
Reference in a new issue