summaryrefslogtreecommitdiffstats
path: root/ansible_collections/community/crypto/plugins/modules/acme_inspect.py
blob: c7ee49765fe2db0bc054ad92f6b91372bde0868e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright (c) 2018 Felix Fontein (@felixfontein)
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import absolute_import, division, print_function
__metaclass__ = type


DOCUMENTATION = r'''
---
module: acme_inspect
author: "Felix Fontein (@felixfontein)"
short_description: Send direct requests to an ACME server
description:
  - "Allows to send direct requests to an ACME server with the
     L(ACME protocol,https://tools.ietf.org/html/rfc8555),
     which is supported by CAs such as L(Let's Encrypt,https://letsencrypt.org/)."
  - "This module can be used to debug failed certificate request attempts,
     for example when M(community.crypto.acme_certificate) fails or encounters a problem which
     you wish to investigate."
  - "The module can also be used to directly access features of an ACME servers
     which are not yet supported by the Ansible ACME modules."
notes:
  - "The O(account_uri) option must be specified for properly authenticated
     ACME v2 requests (except a C(new-account) request)."
  - "Using the C(ansible) tool, M(community.crypto.acme_inspect) can be used to directly execute
     ACME requests without the need of writing a playbook. For example, the
     following command retrieves the ACME account with ID 1 from Let's Encrypt
     (assuming C(/path/to/key) is the correct private account key):
     C(ansible localhost -m acme_inspect -a \"account_key_src=/path/to/key
     acme_directory=https://acme-v02.api.letsencrypt.org/directory acme_version=2
     account_uri=https://acme-v02.api.letsencrypt.org/acme/acct/1 method=get
     url=https://acme-v02.api.letsencrypt.org/acme/acct/1\")"
seealso:
  - name: Automatic Certificate Management Environment (ACME)
    description: The specification of the ACME protocol (RFC 8555).
    link: https://tools.ietf.org/html/rfc8555
  - name: ACME TLS ALPN Challenge Extension
    description: The specification of the C(tls-alpn-01) challenge (RFC 8737).
    link: https://www.rfc-editor.org/rfc/rfc8737.html
extends_documentation_fragment:
  - community.crypto.acme.basic
  - community.crypto.acme.account
  - community.crypto.attributes
  - community.crypto.attributes.actiongroup_acme
attributes:
  check_mode:
    support: none
  diff_mode:
    support: none
options:
  url:
    description:
      - "The URL to send the request to."
      - "Must be specified if O(method) is not V(directory-only)."
    type: str
  method:
    description:
      - "The method to use to access the given URL on the ACME server."
      - "The value V(post) executes an authenticated POST request. The content
         must be specified in the O(content) option."
      - "The value V(get) executes an authenticated POST-as-GET request for ACME v2,
         and a regular GET request for ACME v1."
      - "The value V(directory-only) only retrieves the directory, without doing
         a request."
    type: str
    default: get
    choices:
    - get
    - post
    - directory-only
  content:
    description:
      - "An encoded JSON object which will be sent as the content if O(method)
         is V(post)."
      - "Required when O(method) is V(post), and not allowed otherwise."
    type: str
  fail_on_acme_error:
    description:
      - "If O(method) is V(post) or V(get), make the module fail in case an ACME
         error is returned."
    type: bool
    default: true
'''

EXAMPLES = r'''
- name: Get directory
  community.crypto.acme_inspect:
    acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
    acme_version: 2
    method: directory-only
  register: directory

- name: Create an account
  community.crypto.acme_inspect:
    acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
    acme_version: 2
    account_key_src: /etc/pki/cert/private/account.key
    url: "{{ directory.newAccount}}"
    method: post
    content: '{"termsOfServiceAgreed":true}'
  register: account_creation
  # account_creation.headers.location contains the account URI
  # if creation was successful

- name: Get account information
  community.crypto.acme_inspect:
    acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
    acme_version: 2
    account_key_src: /etc/pki/cert/private/account.key
    account_uri: "{{ account_creation.headers.location }}"
    url: "{{ account_creation.headers.location }}"
    method: get

- name: Update account contacts
  community.crypto.acme_inspect:
    acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
    acme_version: 2
    account_key_src: /etc/pki/cert/private/account.key
    account_uri: "{{ account_creation.headers.location }}"
    url: "{{ account_creation.headers.location }}"
    method: post
    content: '{{ account_info | to_json }}'
  vars:
    account_info:
      # For valid values, see
      # https://tools.ietf.org/html/rfc8555#section-7.3
      contact:
      - mailto:me@example.com

- name: Create certificate order
  community.crypto.acme_certificate:
    acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
    acme_version: 2
    account_key_src: /etc/pki/cert/private/account.key
    account_uri: "{{ account_creation.headers.location }}"
    csr: /etc/pki/cert/csr/sample.com.csr
    fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt
    challenge: http-01
  register: certificate_request

# Assume something went wrong. certificate_request.order_uri contains
# the order URI.

- name: Get order information
  community.crypto.acme_inspect:
    acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
    acme_version: 2
    account_key_src: /etc/pki/cert/private/account.key
    account_uri: "{{ account_creation.headers.location }}"
    url: "{{ certificate_request.order_uri }}"
    method: get
  register: order

- name: Get first authz for order
  community.crypto.acme_inspect:
    acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
    acme_version: 2
    account_key_src: /etc/pki/cert/private/account.key
    account_uri: "{{ account_creation.headers.location }}"
    url: "{{ order.output_json.authorizations[0] }}"
    method: get
  register: authz

- name: Get HTTP-01 challenge for authz
  community.crypto.acme_inspect:
    acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
    acme_version: 2
    account_key_src: /etc/pki/cert/private/account.key
    account_uri: "{{ account_creation.headers.location }}"
    url: "{{ authz.output_json.challenges | selectattr('type', 'equalto', 'http-01') }}"
    method: get
  register: http01challenge

- name: Activate HTTP-01 challenge manually
  community.crypto.acme_inspect:
    acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
    acme_version: 2
    account_key_src: /etc/pki/cert/private/account.key
    account_uri: "{{ account_creation.headers.location }}"
    url: "{{ http01challenge.url }}"
    method: post
    content: '{}'
'''

RETURN = '''
directory:
  description: The ACME directory's content
  returned: always
  type: dict
  sample:
    {
      "a85k3x9f91A4": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417",
      "keyChange": "https://acme-v02.api.letsencrypt.org/acme/key-change",
      "meta": {
          "caaIdentities": [
              "letsencrypt.org"
          ],
          "termsOfService": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf",
          "website": "https://letsencrypt.org"
      },
      "newAccount": "https://acme-v02.api.letsencrypt.org/acme/new-acct",
      "newNonce": "https://acme-v02.api.letsencrypt.org/acme/new-nonce",
      "newOrder": "https://acme-v02.api.letsencrypt.org/acme/new-order",
      "revokeCert": "https://acme-v02.api.letsencrypt.org/acme/revoke-cert"
    }
headers:
  description: The request's HTTP headers (with lowercase keys)
  returned: always
  type: dict
  sample:
    {
      "boulder-requester": "12345",
      "cache-control": "max-age=0, no-cache, no-store",
      "connection": "close",
      "content-length": "904",
      "content-type": "application/json",
      "cookies": {},
      "cookies_string": "",
      "date": "Wed, 07 Nov 2018 12:34:56 GMT",
      "expires": "Wed, 07 Nov 2018 12:44:56 GMT",
      "link": '<https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf>;rel="terms-of-service"',
      "msg": "OK (904 bytes)",
      "pragma": "no-cache",
      "replay-nonce": "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGH",
      "server": "nginx",
      "status": 200,
      "strict-transport-security": "max-age=604800",
      "url": "https://acme-v02.api.letsencrypt.org/acme/acct/46161",
      "x-frame-options": "DENY"
    }
output_text:
  description: The raw text output
  returned: always
  type: str
  sample: "{\\n  \\\"id\\\": 12345,\\n  \\\"key\\\": {\\n    \\\"kty\\\": \\\"RSA\\\",\\n ..."
output_json:
  description: The output parsed as JSON
  returned: if output can be parsed as JSON
  type: dict
  sample:
    - id: 12345
    - key:
      - kty: RSA
      - ...
'''

from ansible.module_utils.common.text.converters import to_native, to_bytes, to_text

from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
    create_backend,
    create_default_argspec,
    ACMEClient,
)

from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
    ACMEProtocolException,
    ModuleFailException,
)


def main():
    argument_spec = create_default_argspec(require_account_key=False)
    argument_spec.update_argspec(
        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),
    )
    argument_spec.update(
        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],
        ),
    )
    module = argument_spec.create_ansible_module()
    backend = create_backend(module, False)

    result = dict()
    changed = False
    try:
        # Get hold of ACMEClient and ACMEAccount objects (includes directory)
        client = ACMEClient(module, backend)
        method = module.params['method']
        result['directory'] = client.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 = client.get_request(url, parse_json_result=False, fail_on_error=False)
            elif method == 'post':
                changed = True  # only POSTs can change
                data, info = client.send_signed_request(
                    url, to_bytes(module.params['content']), parse_json_result=False, encode_payload=False, fail_on_error=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'] = module.from_json(to_text(data))
            except Exception as dummy:
                pass
            # Fail if error was returned
            if fail_on_acme_error and info['status'] >= 400:
                raise ACMEProtocolException(module, info=info, content=data)
        # Done!
        module.exit_json(changed=changed, **result)
    except ModuleFailException as e:
        e.do_fail(module, **result)


if __name__ == '__main__':
    main()