Repository: ansible/ansible

Issue:
letsencrypt does not create a full-chain cert
_From @aioue on December 1, 2016 14:3_

##### ISSUE TYPE
 - Bug Report

##### COMPONENT NAME
letsencrypt

##### ANSIBLE VERSION
<!--- Paste verbatim output from “ansible --version” between quotes below -->
```
ansible 2.3.0 (devel c064dce791) last updated 2016/10/19 15:21:39 (GMT +100)
  lib/ansible/modules/core: (detached HEAD b59b5d36e0) last updated 2016/10/19 15:22:00 (GMT +100)
  lib/ansible/modules/extras: (detached HEAD 3f77bb6857) last updated 2016/10/18 17:26:03 (GMT +100)
  config file =
  configured module search path = Default w/o overrides
```

##### CONFIGURATION
<!---
pipelining = True
-->

##### OS / ENVIRONMENT
<!---
OS X 10.12.1 -> local actions
-->

##### SUMMARY
letsencrypt does not create a full-chain cert - it is missing the intermediate cert and so things like websockets and mobile browsers break.

##### STEPS TO REPRODUCE
<!---
Run playbook in module example.
-->

##### EXPECTED RESULTS
Full chain cert created
or
cert, intermediate files created

##### ACTUAL RESULTS
cert, with no intermediate chain cert created.

_Copied from original issue: ansible/ansible-modules-extras#3607_


Discussion:
_From @ansibot on December 1, 2016 14:24_

<!-- @aioue: Thanks for filing this bug report! -->
@mgruener ping, this issue is waiting for your response.
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: issue_bug_new --->
As a workaround I'm currently doing a `get_url` to https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem, `slurp`ing that and the signed cert into variables and then creating the full-chain cert using `copy` with `contents` of those two variables (signed cert first followed by the intermediate cert).  It's a bit of a mess but it works.


The [acme spec](https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md) says that a link to the matching intermediate certificates is provided in the head of the certificate download GET resource:

> The "up" link relation is used with challenge resources to indicate the authorization resource to which a challenge belongs. It is also used from certificate resources to indicate a resource from which the client may fetch a chain of CA certificates that could be used to validate the certificate in the original resource.

It would be great if the module could download the intermediate certs using that link.

I do think the option to save the intermediate certs out to a separate file would useful too - you need that intermediate cert if you are going to use OCSP stapling (`SSLCACertificateFile` in Apache or `ssl_trusted_certificate` in nginx).

Having the module download/ update the intermediate cert when it pulls a new certificate would have the benefit of always being sure that we have the latest intermediate cert, in case letsencrypt update it.

Resolving patch:
diff --git a/lib/ansible/modules/web_infrastructure/letsencrypt.py b/lib/ansible/modules/web_infrastructure/letsencrypt.py
--- a/lib/ansible/modules/web_infrastructure/letsencrypt.py
+++ b/lib/ansible/modules/web_infrastructure/letsencrypt.py
@@ -101,6 +101,11 @@
     description: The destination file for the certificate.
     required: true
     aliases: ['cert']
+  fullchain:
+    description: Include the full certificate chain in the destination file.
+    required: false
+    default: false
+    version_added: 2.4
   remaining_days:
     description:
       - "The number of days the certificate must have left being valid.
@@ -709,10 +714,21 @@ def _new_cert(self):
             "csr": nopad_b64(out),
         }
         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 = [chain_result.read()]
+
         if info['status'] not in [200, 201]:
             self.module.fail_json(msg="Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result))
         else:
-            return {'cert': result, 'uri': info['location']}
+            return {'cert': result, 'uri': info['location'], 'chain': chain}
 
     def _der_to_pem(self, der_cert):
         '''
@@ -764,6 +780,11 @@ def get_certificate(self):
         cert = self._new_cert()
         if cert['cert'] is not None:
             pem_cert = self._der_to_pem(cert['cert'])
+
+            chain = [self._der_to_pem(link) for link in cert.get('chain', [])]
+            if chain and self.module.params['fullchain']:
+                pem_cert += "\n".join(chain)
+
             if write_file(self.module, self.dest, pem_cert):
                 self.cert_days = get_cert_days(self.module, self.dest)
                 self.changed = True
@@ -779,6 +800,7 @@ def main():
             challenge=dict(required=False, default='http-01', choices=['http-01', 'dns-01', 'tls-sni-02'], type='str'),
             csr=dict(required=True, aliases=['src'], type='path'),
             data=dict(required=False, no_log=True, default=None, type='dict'),
+            fullchain=dict(required=False, default=True, type='bool'),
             dest=dict(required=True, aliases=['cert'], type='path'),
             remaining_days=dict(required=False, default=10, type='int'),
         ),


Test patch: