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.
I suspect the (proper) fix for this is imminent, but I wanted a solution, so using @8w's suggestions, I added these steps to my role:

```
- name: WORKAROUND - download intermediate certs to server
  get_url: url=https://letsencrypt.org/certs/{{ item }} dest=/etc/ssl/letsencrypt/{{ item }} mode=0444
  with_items:
  - lets-encrypt-x3-cross-signed.pem
  - lets-encrypt-x4-cross-signed.pem

- name: WORKAROUND - make a fullchain cert from components
  shell: cat {{ domain }}.key lets-encrypt-x3-cross-signed.pem lets-encrypt-x4-cross-signed.pem {{ domain }}.csr > {{ domain }}.chain.pem
  args:
    chdir: /etc/ssl/letsencrypt
  when: challenge|changed
```
The resulting chaincert seems to work well enough, although YMMV.
I don't think simply outputting the fullchain file and nothing else is a good idea. For example, for OCSP stapling, you need the root chain (root certificate + intermediate certificate) in one file as well. So why not writing the intermediate certificate to its own file (or returning it as data so a user could do whatever she needs do do with 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
@@ -131,6 +131,13 @@
       - "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.
+    required: false
+    default: null
+    aliases: ['chain']
+    version_added: 2.5
   remaining_days:
     description:
       - "The number of days the certificate must have left being valid.
@@ -175,12 +182,13 @@
 #     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
+- name: Let the challenge be validated and retrieve the cert and intermediate certificate
   letsencrypt:
     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 ###
@@ -207,12 +215,14 @@
 #     ttl: 60
 #     value: '"{{ item.value[challenge].resource_value }}"'
 
-- name: Let the challenge be validated and retrieve the cert
+- name: Let the challenge be validated and retrieve the cert and intermediate certificate
   letsencrypt:
     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
@@ -739,8 +749,9 @@ def __init__(self, module):
         self.version = module.params['acme_version']
         self.challenge = module.params['challenge']
         self.csr = module.params['csr']
-        self.dest = module.get('dest')
-        self.fullchain_dest = module.get('fullchain_dest')
+        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']
@@ -1103,6 +1114,9 @@ def get_certificate(self):
                 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 main():
     module = AnsibleModule(
@@ -1119,6 +1133,7 @@ def main():
             data=dict(required=False, no_log=True, 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'),
         ),
         required_one_of=(


Test patch: