From 92d9569bc9785a6906575bed77a83cbe88f14323 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Mon, 29 Oct 2018 10:32:53 +0100 Subject: [PATCH] ACME: add support for POST-as-GET if GET fails with 405. (#44988) * Add support for POST-as-GET if GET fails with 405. * Bumping ACME test container version to 1.4. This includes letsencrypt/pebble#162 and letsencrypt/pebble#168. * Also use POST-as-GET for account data retrival. This is not yet supported by any ACME server (see letsencrypt/pebble#171), so we fall back to a regular empty update if a 'malformedRequest' error is returned. * Using newest ACME test container image. Includes letsencrypt/pebble#171 and letsencrypt/pebble#172, which make Pebble behave closer to the current specs. * Remove workaround for old Pebble version. * Add changelog entry. * First try POST-as-GET, then fall back to unauthenticated GET. --- .../fragments/44988-acme-post-as-get.yaml | 2 + lib/ansible/module_utils/acme.py | 52 +++++++++++++++---- test/runner/lib/cloud/acme.py | 2 +- 3 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 changelogs/fragments/44988-acme-post-as-get.yaml diff --git a/changelogs/fragments/44988-acme-post-as-get.yaml b/changelogs/fragments/44988-acme-post-as-get.yaml new file mode 100644 index 0000000000..f2968fd6df --- /dev/null +++ b/changelogs/fragments/44988-acme-post-as-get.yaml @@ -0,0 +1,2 @@ +bugfixes: +- "ACME modules support `POST-as-GET `__ and will be able to access Let's Encrypt ACME v2 endpoint after November 1st, 2019." diff --git a/lib/ansible/module_utils/acme.py b/lib/ansible/module_utils/acme.py index e4f73e42eb..8e917f8a09 100644 --- a/lib/ansible/module_utils/acme.py +++ b/lib/ansible/module_utils/acme.py @@ -437,7 +437,7 @@ class ACMEDirectory(object): self.directory_root = module.params['acme_directory'] self.version = module.params['acme_version'] - self.directory, dummy = account.get_request(self.directory_root) + self.directory, dummy = account.get_request(self.directory_root, get_only=True) # Check whether self.version matches what we expect if self.version == 1: @@ -520,7 +520,10 @@ class ACMEAccount(object): def sign_request(self, protected, payload, key_data): try: - payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8')) + if payload is None: + payload64 = '' + else: + payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8')) protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8')) except Exception as e: raise ModuleFailException("Failed to encode payload / headers as JSON: {0}".format(e)) @@ -535,6 +538,9 @@ class ACMEAccount(object): Sends a JWS signed HTTP POST request to the ACME server and returns the response as dictionary https://tools.ietf.org/html/draft-ietf-acme-acme-14#section-6.2 + + If payload is None, a POST-as-GET is performed. + (https://tools.ietf.org/html/draft-ietf-acme-acme-15#section-6.3) ''' key_data = key_data or self.key_data jws_header = jws_header or self.jws_header @@ -580,14 +586,31 @@ class ACMEAccount(object): return result, info - def get_request(self, uri, parse_json_result=True, headers=None): - resp, info = fetch_url(self.module, uri, method='GET', headers=headers) + def get_request(self, uri, parse_json_result=True, headers=None, get_only=False): + ''' + Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback + to GET if server replies with a status code of 405. + ''' + if not get_only and self.version != 1: + # Try POST-as-GET + content, info = self.send_signed_request(uri, None, parse_json_result=False) + if info['status'] == 405: + # Instead, do unauthenticated GET + get_only = True + else: + # Do unauthenticated GET + get_only = True - try: - content = resp.read() - except AttributeError: - content = info.get('body') + if get_only: + # Perform unauthenticated GET + resp, info = fetch_url(self.module, uri, method='GET', headers=headers) + try: + content = resp.read() + except AttributeError: + content = info.get('body') + + # Process result if parse_json_result: result = {} if content: @@ -668,10 +691,19 @@ class ACMEAccount(object): ''' if self.uri is None: raise ModuleFailException("Account URI unknown") - data = {} if self.version == 1: + data = {} data['resource'] = 'reg' - result, info = self.send_signed_request(self.uri, data) + result, info = self.send_signed_request(self.uri, data) + else: + # try POST-as-GET first (draft-15 or newer) + data = None + result, info = self.send_signed_request(self.uri, data) + # check whether that failed with a malformed request error + if info['status'] >= 400 and result.get('type') == 'urn:ietf:params:acme:error:malformed': + # retry as a regular POST (with no changed data) for pre-draft-15 ACME servers + data = {} + result, info = self.send_signed_request(self.uri, data) if info['status'] in (400, 403) and result.get('type') == 'urn:ietf:params:acme:error:unauthorized': # Returned when account is deactivated return None diff --git a/test/runner/lib/cloud/acme.py b/test/runner/lib/cloud/acme.py index 4e4646e9fa..a5cdefd44b 100644 --- a/test/runner/lib/cloud/acme.py +++ b/test/runner/lib/cloud/acme.py @@ -43,7 +43,7 @@ class ACMEProvider(CloudProvider): if os.environ.get('ANSIBLE_ACME_CONTAINER'): self.image = os.environ.get('ANSIBLE_ACME_CONTAINER') else: - self.image = 'quay.io/ansible/acme-test-container:1.3.0' + self.image = 'quay.io/ansible/acme-test-container:1.4.1' self.container_name = '' def _wait_for_service(self, protocol, acme_host, port, local_part, name):