Let’s Encrypt now has the possibility to create wildcard certificates which makes things much easier if you are hosting many different sites or servers with the same domain which all need SSL certificates. Here I will describe how to implement that.
I am using the Dehydrated client and BIND as my DNS server. Wildcard certificates are only supported with the DNS-01 challenge type. This means that you’ll need to be able to modify DNS TXT records for your domains.
First clone the Dehydrated client from Github:
1 2 |
git clone https://github.com/lukas2511/dehydrated.git cd dehydrated |
You can specify most of the configuration option via the command line. However if you want to use the Let’s encrypt staging environment first for testing (recommended!) then you need to specify the correct URL. Create a new file named “config” in the “dehydrated” directory with the following content:
1 2 |
# Path to certificate authority (default: https://acme-v02.api.letsencrypt.org/directory) CA="https://acme-staging-v02.api.letsencrypt.org/directory" |
You can just comment out that line later if you want to use the production environment if your tests are successful.
You also need a hook script so that the Dehydrated client is able to add the needed entries to your DNS server. Here is the script I am using which modifies my BIND configuration via the NSUPDATE command. You will need to specify the path to your keyfile which is used to get access to your BIND server. For more information how to implement access via NSUPDATE you can check this article. Save the following script as “hook.sh” in the “dehydrated” directory and make it executable with “chmod +x hook.sh”:
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 323 324 325 326 327 328 329 330 331 |
#!/usr/bin/env bash # letsencrypt.sh dns-01 challenge RFC2136 hook. # Copyright (c) 2016 Tom Laermans. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. # Load letsencrypt.sh config ($CONFIG is exported by letsencrypt.sh) #. $CONFIG # All the below settings can be set in the letsencrypt.sh configuration file as well. # They will not be overwritten by the statements below if they already exist in that configuration. # NSUPDATE - Path to nsupdate binary [ -z "${NSUPDATE}" ] && NSUPDATE="/usr/bin/nsupdate -k /path/to/keyfile.private" # SERVER - Master DNS server IP [ -z "${SERVER}" ] && SERVER="dns.acme.com" # PORT - Master DNS port (likely to be 53) [ -z "${PORT}" ] && PORT=53 # TTL - DNS Time-To-Live of ACME TXT record [ -z "${TTL}" ] && TTL=300 # DESTINATION - Copy files to subdirectory of DESTINATION upon successful certificate request [ -z "${DESTINATION}" ] && DESTINATION= # CERT_OWNER - If DESTINATION and CERT_OWNER are set, chown files to CERT_OWNER after copy [ -z "${CERT_OWNER}" ] && CERT_OWNER= # CERT_GROUP - If DESTINATION, CERT_OWNER and CERT_GROUP are set, chown files to CERT_GROUP after copy [ -z "${CERT_GROUP}" ] && CERT_GROUP= # CERT_MODE - If DESTINATION and CERT_MODE are set, chmod files to CERT_MODE after copy [ -z "${CERT_MODE}" ] && CERT_MODE= # CERTDIR_OWNER - If DESTINATION and CERTDIR_OWNER are set, chown files to CERTDIR_OWNER after copy [ -z "${CERTDIR_OWNER}" ] && CERTDIR_OWNER= # CERTDIR_GROUP - If DESTINATION, CERTDIR_OWNER and CERTDIR_GROUP are set, chown files to CERTDIR_GROUP after copy [ -z "${CERTDIR_GROUP}" ] && CERTDIR_GROUP= # CERTDIR_MODE - If DESTINATION and CERT_MODE are set, chmod files to CERT_MODE after copy [ -z "${CERTDIR_MODE}" ] && CERTDIR_MODE= # ATTEMPTS - Wait $ATTEMPTS times $SLEEP seconds for propagation to succeed, then bail out. [ -z "${ATTEMPTS}" ] && ATTEMPTS=30 # SLEEP - Amount of seconds to sleep before retrying propagation check. [ -z "${SLEEP}" ] && SLEEP=60 # DOMAINS_TXT - Path to the domains.txt file containing all requested certificates. [ -z "${DOMAINS_TXT}" ] && DOMAINS_TXT="${BASEDIR}/domains.txt" _log() { echo >&2 " + ${@}" } _checkdns() { local ATTEMPT="${1}" DOMAIN="${2}" TOKEN_VALUE="${3}" if [ $ATTEMPT -gt $ATTEMPTS ]; then _log "Propagation check failed after ${ATTEMPTS} attempts. Bailing out!" exit 2 fi _log "Checking for dns propagation via Google's recursor... (${ATTEMPT}/${ATTEMPTS})" # host -t txt _acme-challenge.${DOMAIN} 8.8.8.8 | grep ${TOKEN_VALUE} >/dev/null 2>&1 host -t txt _acme-challenge.${DOMAIN} 8.8.8.8 | grep -- ${TOKEN_VALUE} >/dev/null 2>&1 if [ "$?" -eq 0 ]; then host -t txt _acme-challenge.${DOMAIN} 192.174.68.104 | grep -- ${TOKEN_VALUE} >/dev/null 2>&1 if [ "$?" -eq 0 ]; then host -t txt _acme-challenge.${DOMAIN} 176.97.158.104 | grep -- ${TOKEN_VALUE} >/dev/null 2>&1 if [ "$?" -eq 0 ]; then _log "Propagation success!" return fi fi else _log "Waiting ${SLEEP}s..." sleep ${SLEEP} _checkdns $((ATTEMPT+1)) ${DOMAIN} ${TOKEN_VALUE} fi } deploy_challenge() { local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" # This hook is called once for every domain that needs to be # validated, including any alternative names you may have listed. # # Parameters: # - DOMAIN # The domain name (CN or subject alternative name) being # validated. # - TOKEN_FILENAME # The name of the file containing the token to be served for HTTP # validation. Should be served by your web server as # /.well-known/acme-challenge/${TOKEN_FILENAME}. # - TOKEN_VALUE # The token value that needs to be served for validation. For DNS # validation, this is what you want to put in the _acme-challenge # TXT record. For HTTP validation it is the value that is expected # be found in the $TOKEN_FILENAME file. _log "Adding ACME challenge record via RFC2136 update to ${SERVER}..." printf "server %s %s\nupdate add _acme-challenge.%s. %d in TXT \"%s\"\n\n" "${SERVER}" "${PORT}" "${DOMAIN}" "${TTL}" "${TOKEN_VALUE}" | $NSUPDATE > /dev/null 2>&1 if [ "$?" -ne 0 ]; then _log "Failure reported by nsupdate. Bailing out!" exit 2 fi # Allow at least a little time to propagate to slaves before asking Google sleep 5 _checkdns 1 ${DOMAIN} ${TOKEN_VALUE} } clean_challenge() { local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" # This hook is called after attempting to validate each domain, # whether or not validation was successful. Here you can delete # files or DNS records that are no longer needed. # # The parameters are the same as for deploy_challenge. _log "Removing ACME challenge record via RFC2136 update to ${SERVER}..." printf "server %s %s\nupdate delete _acme-challenge.%s. %d in TXT \"%s\"\n\n" "${SERVER}" "${PORT}" "${DOMAIN}" "${TTL}" "${TOKEN_VALUE}" | $NSUPDATE > /dev/null 2>&1 if [ "$?" -ne 0 ]; then _log "Failure reported by nsupdate. Bailing out!" exit 2 fi } deploy_cert() { local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}" # This hook is called once for each certificate that has been # produced. Here you might, for instance, copy your new certificates # to service-specific locations and reload the service. # # Parameters: # - DOMAIN # The primary domain name, i.e. the certificate common # name (CN). # - KEYFILE # The path of the file containing the private key. # - CERTFILE # The path of the file containing the signed certificate. # - FULLCHAINFILE # The path of the file containing the full certificate chain. # - CHAINFILE # The path of the file containing the intermediate certificate(s). # - TIMESTAMP # Timestamp when the specified certificate was created. # Simple example: Copy file to nginx config # cp "${KEYFILE}" "${FULLCHAINFILE}" /etc/nginx/ssl/; chown -R nginx: /etc/nginx/ssl # systemctl reload nginx # If destination is set, copy/chown/chmod certificate files if [ "$DESTINATION" != "" ]; then _log "Copying certificate files to destination repository" mkdir -p ${DESTINATION}/${DOMAIN} if [ "$CERTDIR_MODE" != "" ]; then chmod ${CERTDIR_MODE} ${DESTINATION}/${DOMAIN} fi if [ "$CERTDIR_OWNER" != "" ]; then chown ${CERTDIR_OWNER}:${CERTDIR_GROUP} ${DESTINATION}/${DOMAIN} fi if [ "$CERTDIR_MODE" != "" ]; then chmod ${CERTDIR_MODE} ${DESTINATION}/${DOMAIN} fi for FILE in ${KEYFILE} ${CERTFILE} ${CHAINFILE} do FILENAME=$(basename $FILE) cp ${FILE} ${DESTINATION}/${DOMAIN} if [ "$CERT_OWNER" != "" ]; then chown ${CERT_OWNER}:${CERT_GROUP} ${DESTINATION}/${DOMAIN}/${FILENAME} fi if [ "$CERT_MODE" != "" ]; then chmod ${CERT_MODE} ${DESTINATION}/${DOMAIN}/${FILENAME} fi done fi # Add DOMAIN to domains.txt if not already there grep ^$HOST\$ ${DOMAINS_TXT} > /dev/null 2>&1 if [ "$?" -ne 0 ]; then echo ${DOMAIN} >> ${DOMAINS_TXT} fi } unchanged_cert() { local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" # This hook is called once for each certificate that is still # valid and therefore wasn't reissued. # # Parameters: # - DOMAIN # The primary domain name, i.e. the certificate common # name (CN). # - KEYFILE # The path of the file containing the private key. # - CERTFILE # The path of the file containing the signed certificate. # - FULLCHAINFILE # The path of the file containing the full certificate chain. # - CHAINFILE # The path of the file containing the intermediate certificate(s). # NOOP } invalid_challenge() { # This hook is called at the beginning of a dehydrated command local DOMAIN="${1}" RESPONSE="${2}" # This hook is called if the challenge response has failed, so domain # owners can be aware and act accordingly. # # Parameters: # - DOMAIN # The primary domain name, i.e. the certificate common # name (CN). # - RESPONSE # The response that the verification server returned # Simple example: Send mail to root # printf "Subject: Validation of ${DOMAIN} failed!\n\nOh noez!" | sendmail root : } request_failure() { local STATUSCODE="${1}" REASON="${2}" REQTYPE="${3}" HEADERS="${4}" # This hook is called when an HTTP request fails (e.g., when the ACME # server is busy, returns an error, etc). It will be called upon any # response code that does not start with '2'. Useful to alert admins # about problems with requests. # # Parameters: # - STATUSCODE # The HTML status code that originated the error. # - REASON # The specified reason for the error. # - REQTYPE # The kind of request that was made (GET, POST...) # Simple example: Send mail to root # printf "Subject: HTTP request failed failed!\n\nA http request failed with status ${STATUSCODE}!" | sendmail root } generate_csr() { local DOMAIN="${1}" CERTDIR="${2}" ALTNAMES="${3}" # This hook is called before any certificate signing operation takes place. # It can be used to generate or fetch a certificate signing request with external # tools. # The output should be just the cerificate signing request formatted as PEM. # # Parameters: # - DOMAIN # The primary domain as specified in domains.txt. This does not need to # match with the domains in the CSR, it's basically just the directory name. # - CERTDIR # Certificate output directory for this particular certificate. Can be used # for storing additional files. # - ALTNAMES # All domain names for the current certificate as specified in domains.txt. # Again, this doesn't need to match with the CSR, it's just there for convenience. # Simple example: Look for pre-generated CSRs # if [ -e "${CERTDIR}/pre-generated.csr" ]; then # cat "${CERTDIR}/pre-generated.csr" # fi } startup_hook() { # This hook is called at the beginning of a dehydrated command : } exit_hook() { # This hook is called at the end of a dehydrated command and can be used # to do some final (cleanup or other) tasks. : } HANDLER="$1"; shift if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|deploy_cert|unchanged_cert|invalid_challenge|request_failure|generate_csr|startup_hook|exit_hook)$ ]]; then "$HANDLER" "$@" fi |
In line 25 you need to specify the path to your key file which you created for dynamic BIND updates. And in line 29 you need to specify the IP or host name of your BIND DNS server.
Now you need first to register an account with Let’s Encrypt. This is needed only one time (for both the staging and the production environment of Let’s Encrypt). You do that with the following command:
1 |
./dehydrated --register --accept-terms |
As a result you should see something like that:
1 2 3 4 |
# INFO: Using main config file /root/dehydrated/config + Generating account key... + Registering account key with ACME server... + Done! |
In the “accounts” directory you will now find a sub-directory with your registration key and information.
Now you are ready to create your first wildcard certificate. Run the following command (change the “acme.com” domain below to your domain for which you want to create a certificate and the path “/root/dehydrated” to the path where you cloned the Dehydrated client):
1 |
/root/dehydrated/dehydrated -c -d "*.acme.com acme.com" --alias _wildcard.acme.com -k /root/dehydrated/hook.sh -t dns-01 |
Please note that you also should also include your domain without the “*.” as SAN (Subject Alternative Name) into the certificate, otherwise you will not be able to use “https://acme.com” with your certificate!
You should see an output like the following:
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 |
# INFO: Using main config file /root/dehydrated/config Processing *.acme.com + Signing domains... + Generating private key... + Generating signing request... + Requesting new certificate order from CA... + Received 1 authorizations URLs from the CA + Handling authorization for acme.com + 1 pending challenge(s) + Deploying challenge tokens... + Adding ACME challenge record via RFC2136 update to dns.acme.com... + Checking for dns propagation via Google's recursor... (1/30) + Waiting 60s... + Checking for dns propagation via Google's recursor... (2/30) + Waiting 60s... + Checking for dns propagation via Google's recursor... (3/30) + Waiting 60s... + Checking for dns propagation via Google's recursor... (4/30) + Waiting 60s... + Checking for dns propagation via Google's recursor... (5/30) + Propagation success! + Responding to challenge for acme.com authorization... + Removing ACME challenge record via RFC2136 update to dns.acme.com... + Challenge is valid! + Requesting certificate... + Checking certificate... + Done! + Creating fullchain.pem.. |
The number of tries for the DNS propagation depends on how fast the change to your BIND server will be propagated to the outside world. The kook script checks three different external DNS servers and all of them needs to respond to the DNS challenge correctly.
As a result you now should have the certificate files in the directory “certs/_wildcard.acme.com”:
1 2 3 4 5 6 7 |
cert.pem: Contains your certificate privkey.pem: Contains the private key of your certificate chain.pem: Contains the upper certificate in the certificate chain fullchain.pem: Contains the full certificate chain from the root certificate down to level to your certificate |
You can then use the certificate e.g. in your Apache web server by including the following lines in your SSL section:
1 2 3 |
SSLCertificateFile "/path/to/your/certificate/files/_wildcard.acme.com/cert.pem" SSLCertificateKeyFile "/path/to/your/certificate/files/_wildcard.acme.com/privkey.pem" SSLCertificateChainFile "/path/to/your/certificate/files/_wildcard.acme.com/chain.pem" |
In the “config” file you can specify where the Dehydrated client will store the generated certificates. Just add the following lines to your “config” file:
1 2 |
# Output directory for generated certificates CERTDIR="/path/to/your/certificate/files" |
The certificate is valid for three months. So you need to run that task on a regular basis. I am running it every day. As long as the certificate is valid at least 30 days, nothing will happen. Otherwise a new certificate will be generated (and you you need to restart your application e.g. Apache, in order to activate it). The time frame of 30 days can be changed by adding the following lines to “config”:
1 2 |
# Minimum days before expiration to automatically renew certificate (default: 30) RENEW_DAYS="30" |
There are some more options in the config file. You will find a documented sample file under “docs/examples/config” in the “dehydrated” directory.
Sometime you will need to have the certificate in the PKCS12 format. You can use the following OpenSSL command to create such a file:
1 |
openssl pkcs12 -export -inkey privkey.pem -in cert.pem -name *.acme.com -out _wildcard.acme.com.p12 -password pass:<your_cert_password> |
If you are also using the DANE protcol, then you can generate the necessary DNS entry (for 3 1 1) with the following command:
1 |
printf '_443._tcp.www.acme.com. IN TLSA 3 1 1 %s\n' $(openssl x509 -in cert.pem -noout -pubkey | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | hexdump -ve '/1 "%02x"') |
You will get then something like that:
1 |
_443._tcp.www.acme.com. IN TLSA 3 1 1 78ac2ccae6045c8005851bd53ea2b667840406be5145114f0c2fd7d659397853 |
As you do not want to change the TLSA records every time a certificate renewal takes place, you need to configure Dehydrated to re-use the private key while issuing a new certificate by adding these lines to the “config” file:
1 2 |
# Regenerate private keys instead of just signing new certificates on renewal (default: yes) PRIVATE_KEY_RENEW="no" |
Keep in mind that it is still recommended to change that key from time to time for security reasons. However I would say it is not needed to do that every three months (one time a year should be ok as well).
By the way: You can use the same procedure from above to create non-wildcard domains for just one site/server. Although in this case you might also use the “HTTP-01” challenge mechanism which does also work without access to your DNS server.
I liked the article. He had already tried to implement and had not found. Thank you!
Thanks, Alvaro. Great that it was helpful for you!