diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 116d2a5..135ea05 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -12,6 +12,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -21,8 +22,9 @@ Steps to reproduce the behavior: A clear and concise description of what you expected to happen. **Operating system (please complete the following information):** - - OS: [e.g. Debian 9, Ubuntu 18.04, freeBSD ] - - Bash Version [e.g. GNU bash, version 4.4.12(1)-release (x86_64-pc-linux-gnu)] + +- OS: [e.g. Debian 9, Ubuntu 18.04, freeBSD ] +- Bash Version [e.g. GNU bash, version 4.4.12(1)-release (x86_64-pc-linux-gnu)] **Additional context** Add any other context about the problem here. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 482a7aa..3c8242c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,24 +1,31 @@ # How to contribute -If you are happy writing in bash, please create a PR for any changes you'd like to see included (or bug fixes). +If you are happy writing in bash, please create a PR for any changes +you'd like to see included (or bug fixes). -If you aren't happy writing in bash, please open an issue with as much detail as possible about the issue or what you'd like to see added / improved. +If you aren't happy writing in bash, please open an issue with as much +detail as possible about the issue or what you'd like to see added / +improved. ## Submitting changes -Please update the 'revision history' and version number at the top of the code (without this I can't easily do a merge) +Please update the 'revision history' and version number at the top of +the code (without this I can't easily do a merge) -Please update just one issue per PR. If there are multiple issues, please provide separate PR's one per issue. +Please update just one issue per PR. If there are multiple issues, +please provide separate PR's one per issue. ## Coding conventions -Please see the guidelines at https://github.com/srvrco/getssl/wiki/Bash-Style-guide +Please see the guidelines at ## Testing -Please test with [shellcheck](https://github.com/koalaman/shellcheck), although this will also be tested on github ( via travis) on all PRs. +Please test with [shellcheck](https://github.com/koalaman/shellcheck), +although this will also be tested on github (via travis) on all PRs. -Please remember that the system is used across a wide range of platforms, so if you have access to multiple operating systems, please test on all. +Please remember that the system is used across a wide range of +platforms, so if you have access to multiple operating systems, please +test on all. - -Thanks :) +Thanks :) diff --git a/README.md b/README.md index 8549bda..1d0d3fc 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,76 @@ # getssl -Obtain SSL certificates from the letsencrypt.org ACME server. Suitable for automating the process on remote servers. + +Obtain SSL certificates from the letsencrypt.org ACME server. Suitable +for automating the process on remote servers. ## Features -* **Bash** - It runs on virtually all unix machines, including BSD, most Linux distributions, macOS. -* **Get certificates for remote servers** - The tokens used to provide validation of domain ownership, and the certificates themselves can be automatically copied to remote servers (via ssh, sftp or ftp for tokens). The script doesn't need to run on the server itself. This can be useful if you don't have access to run such scripts on the server itself, e.g. if it's a shared server. -* **Runs as a daily cron** - so certificates will be automatically renewed when required. + +* **Bash** - It runs on virtually all unix machines, including BSD, most + Linux distributions, macOS. +* **Get certificates for remote servers** - The tokens used to provide + validation of domain ownership, and the certificates themselves can be + automatically copied to remote servers (via ssh, sftp or ftp for + tokens). The script doesn't need to run on the server itself. This can + be useful if you don't have access to run such scripts on the server + itself, e.g. if it's a shared server. +* **Runs as a daily cron** - so certificates will be automatically + renewed when required. * **Automatic certificate renewals** -* **Checks certificates are correctly loaded**. After installation of a new certificate it will test the port specified ( see [Server-Types](#server-types) for options ) that the certificate is actually being used correctly. -* **Automatically updates** - The script can automatically update itself with bug fixes etc if required. -* **Extensively configurable** - With a simple configuration file for each certificate it is possible to configure it exactly for your needs, whether a simple single domain or multiple domains across multiple servers on the same certificate. +* **Checks certificates are correctly loaded** - After installation of a + new certificate it will test the port specified ( see + [Server-Types](#server-types) for options ) that the certificate is + actually being used correctly. +* **Automatically updates** - The script can automatically update itself + with bug fixes etc if required. +* **Extensively configurable** - With a simple configuration file for + each certificate it is possible to configure it exactly for your + needs, whether a simple single domain or multiple domains across + multiple servers on the same certificate. * **Supports http and dns challenges** - Full ACME implementation * **Simple and easy to use** -* **Detailed debug info** - Whilst it shouldn't be needed, detailed debug information is available. -* **Reload services** - After a new certificate is obtained then the relevant services (e.g. apache/nginx/postfix) can be reloaded. +* **Detailed debug info** - Whilst it shouldn't be needed, detailed + debug information is available. +* **Reload services** - After a new certificate is obtained then the + relevant services (e.g. apache/nginx/postfix) can be reloaded. * **ACME v1 and V2** - Supports both ACME versions 1 and 2 ## Installation -Since the script is only one file, you can use the following command for a quick installation of GetSSL only: -``` + +Since the script is only one file, you can use the following command for +a quick installation of GetSSL only: + +```sh curl --silent https://raw.githubusercontent.com/srvrco/getssl/master/getssl > getssl ; chmod 700 getssl ``` -This will copy the getssl Bash script to the current location and change the permissions to make it executable for you. -For a more comprehensive installation (e.g. install also helper scripts) use the provided Makefile with each release tarball. Use the `install` target. +This will copy the getssl Bash script to the current location and change +the permissions to make it executable for you. + +For a more comprehensive installation (e.g. install also helper scripts) +use the provided Makefile with each release tarball. Use the `install` +target. You'll find the latest version in the git repository: -``` +```sh git clone https://github.com/srvrco/getssl.git ``` -For Arch Linux there are packages in the AUR, see [here](https://aur.archlinux.org/packages/getssl/) and [there](https://aur.archlinux.org/packages/getssl-git/). +For Arch Linux there are packages in the AUR, see +[here](https://aur.archlinux.org/packages/getssl/) and +[there](https://aur.archlinux.org/packages/getssl-git/). -If you use puppet, there is a [GetSSL Puppet module](https://github.com/dthielking/puppet_getssl) by dthielking +If you use puppet, there is a [GetSSL Puppet +module](https://github.com/dthielking/puppet_getssl) by dthielking ## Overview -GetSSL was written in standard bash ( so it can be run on a server, a desktop computer, or even a virtualbox) and add the checks, and certificates to a remote server ( providing you have a ssh with key, sftp or ftp access to the remote server). +GetSSL was written in standard bash ( so it can be run on a server, a +desktop computer, or even a virtualbox) and add the checks, and +certificates to a remote server ( providing you have a ssh with key, +sftp or ftp access to the remote server). -``` +```getssl getssl ver. 2.02 Obtain SSL certificates from the letsencrypt.org ACME server @@ -63,27 +95,36 @@ Options: Once you have obtained the script (see Installation above), the next step is to use -```./getssl -c yourdomain.com``` +```sh +./getssl -c yourdomain.com +``` -where yourdomain.com is the primary domain name that you want to create a certificate for. This will create the following folders and files. +where yourdomain.com is the primary domain name that you want to create +a certificate for. This will create the following folders and files. -``` +```sh ~/.getssl ~/.getssl/getssl.cfg ~/.getssl/yourdomain.com ~/.getssl/yourdomain.com/getssl.cfg ``` -You can then edit ~/.getssl/getssl.cfg to set the values you want as the default for the majority of your certificates. - -Then edit ~/.getssl/yourdomain.com/getssl.cfg to have the values you want for this specific domain (make sure to uncomment and specify correct `ACL` option, since it is required). +You can then edit `~/.getssl/getssl.cfg` to set the values you want as the +default for the majority of your certificates. -You can then just run; +Then edit `~/.getssl/yourdomain.com/getssl.cfg` to have the values you +want for this specific domain (make sure to uncomment and specify +correct `ACL` option, since it is required). -```getssl yourdomain.com ``` +You can then just run: -and it should run, providing output like; +```sh +getssl yourdomain.com ``` + +and it should run, providing output like: + +```sh Registering account Verify each domain Verifying yourdomain.com @@ -98,30 +139,41 @@ copying private key to ssh:server5:/home/yourdomain/ssl/domain.key copying CA certificate to ssh:server5:/home/yourdomain/ssl/chain.crt reloading SSL services ``` -**This will (by default) use the staging server, so should give you a certificate that isn't trusted ( Fake Let's Encrypt).** + +**This will (by default) use the staging server, so should give you a +certificate that isn't trusted ( Fake Let's Encrypt).** Change the server in your config file to get a fully valid certificate. -**Note:** Verification is done via port 80 (http), port 443 (https) or dns. The certificate can be used (and checked with getssl) on alternate ports. +**Note:** Verification is done via port 80 (http), port 443 (https) or +dns. The certificate can be used (and checked with getssl) on alternate +ports. ## Automating updates I use the following cron -``` + +```cron 23 5 * * * /root/scripts/getssl -u -a -q ``` -The cron will automatically update getssl and renew any certificates, only giving output if there are issues / errors. + +The cron will automatically update getssl and renew any certificates, +only giving output if there are issues / errors. * The -u flag updates getssl if there is a more recent version available. * The -a flag automatically renews any certificates that are due for renewal. -* The -q flag is "quiet" so that it only outputs and emails me if there was an error / issue. +* The -q flag is "quiet" so that it only outputs and emails me if there + was an error / issue. ## Structure -The design aim was to provide flexibility in running the code. The default working directory is ~/.getssl ( which can be modified via the command line) +The design aim was to provide flexibility in running the code. The +default working directory is `~/.getssl` (which can be modified via the +command line). -Within the **working directory** is a config file, getssl.cfg which is a simple bash file containing variables, an example of which is +Within the **working directory** is a config file `getssl.cfg` which is a +simple bash file containing variables, an example of which is: -``` +```getssl # Uncomment and modify any variables you need # The staging server is best for testing (hence set as default) CA="https://acme-staging.api.letsencrypt.org" @@ -143,9 +195,11 @@ RENEW_ALLOW="30" SSLCONF="/usr/lib/ssl/openssl.cnf" ``` -then, within the **working directory** there will be a folder for each certificate (based on its domain name). Within that folder will be a config file (again called getssl.cfg). An example of which is; +then, within the **working directory** there will be a folder for each +certificate (based on its domain name). Within that folder will be a +config file (again called `getssl.cfg`). An example of which is: -``` +```getssl # Uncomment and modify any variables you need # see https://github.com/srvrco/getssl/wiki/Config-variables for details # see https://github.com/srvrco/getssl/wiki/Example-config-files for example configs @@ -195,19 +249,27 @@ RELOAD_CMD="service apache2 reload" #CHECK_REMOTE="true" ``` -If a location for a file starts with ssh: it is assumed the next part of the file is the hostname, followed by a colon, and then the path. -Files will be securely copied using scp, and it assumes that you have a key on the server ( for passwordless access). You can set the user, port etc for the server in your .ssh/config file +If a location for a file starts with `ssh:` it is assumed the next part +of the file is the hostname, followed by a colon, and then the path. +Files will be securely copied using scp, and it assumes that you have a +key on the server (for passwordless access). You can set the user, +port etc for the server in your `.ssh/config` file. -If an ACL starts with ftp: or sftp: it as assumed that the line is in the format "ftp:UserID:Password:servername:/path/to/acme-challenge". sftp requires sshpass. -Note: FTP can be used for copying tokens only and can **not** be used for uploading private key or certificates as it's not a secure method of transfer. +If an ACL starts with `ftp:` or `sftp:` it as assumed that the line is +in the format "ftp:UserID:Password:servername:/path/to/acme-challenge". +sftp requires sshpass. +Note: FTP can be used for copying tokens only +and can **not** be used for uploading private key or certificates as +it's not a secure method of transfer. ssh can also be used for the reload command if using on remote servers. Multiple locations can be defined for a file by separating the locations with a semi-colon. +A typical config file for `example.com` and `www.example.com` on the +same server would be: -A typical config file for example.com and www.example.com on the same server would be -``` +```getssl # uncomment and modify any variables you need # The staging server is best for testing CA="https://acme-staging.api.letsencrypt.org" @@ -231,6 +293,7 @@ RELOAD_CMD="service apache2 reload" ``` ## Server-Types + OpenSSL has built-in support for getting the certificate from a number of SSL services these are available in getssl to check if the certificate is installed correctly @@ -252,23 +315,32 @@ these are available in getssl to check if the certificate is installed correctly | ldaps | 636 | | | port number | | | - ## Revoke a certificate In general revoking a certificate is not required. Usage: `getssl -r path/to/cert path/to/key [CA_server]` -You need to specify both the certificate you want to revoke, and the account or private domain key which was used to sign / obtain the original certificate. The CA_server is an optional parameter and defaults to Let's Encrypt ( "https://acme-v01.api.letsencrypt.org" ) as that is currently the only Certificate Authority using the ACME protocol. - +You need to specify both the certificate you want to revoke, and the +account or private domain key which was used to sign / obtain the +original certificate. The `CA_server` is an optional parameter and +defaults to Let's Encrypt ("") as +that is currently the only Certificate Authority using the ACME +protocol. ## Elliptic curve keys -You can use Elliptic curve keys for both the account key and the domain key (different of course, don't use the same key for both). prime256v1 (NIST P-256) and secp384r1 (NIST P-384) are both fully supported. secp521r1 (NIST P-521) is included in the code, but not currently supported by Let's Encrypt). +You can use Elliptic curve keys for both the account key and the domain +key (different of course, don't use the same key for both). prime256v1 +(NIST P-256) and secp384r1 (NIST P-384) are both fully supported. +secp521r1 (NIST P-521) is included in the code, but not currently +supported by Let's Encrypt). ## Issues / problems / help -If you have any issues, please log them at https://github.com/srvrco/getssl/issues + +If you have any issues, please log them at There are additional help pages on the [wiki](https://github.com/srvrco/getssl/wiki) -If you have any suggestions for improvements then pull requests are welcomed, or raise an issue. +If you have any suggestions for improvements then pull requests are +welcomed, or raise an issue. diff --git a/dns_scripts/dns_add_clouddns b/dns_scripts/dns_add_clouddns new file mode 100755 index 0000000..f20d1ab --- /dev/null +++ b/dns_scripts/dns_add_clouddns @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Need to add your email address and API key to clouddns below or set as env variables +email=${CLOUDDNS_EMAIL:-''} +password=${CLOUDDNS_PASSWORD:-''} +client=${CLOUDDNS_CLIENT:-''} + +# This script adds a token to clouddns DNS for the ACME challenge +# usage dns_add_clouddns "domain name" "token" +# return codes are; +# 0 - success +# 1 - error in input +# 2 - error within internal processing +# 3 - error in result ( domain not found in clouddns etc) + +fulldomain="${1}" +token="${2}" +API='https://admin.vshosting.cloud/clouddns' +LOGIN_API='https://admin.vshosting.cloud/api/public/auth/login' + +# Check initial parameters +if [[ -z "$fulldomain" ]]; then + echo "DNS script requires full domain name as first parameter" + exit 1 +fi +if [[ -z "$token" ]]; then + echo "DNS script requires challenge token as second parameter" + exit 1 +fi +if [[ -z "$email" ]]; then + echo "CLOUDDNS_EMAIL (email) parameter not set" + exit 1 +fi +if [[ -z "$password" ]]; then + echo "CLOUDDNS_PASSWORD (password) parameter not set" + exit 1 +fi +if [[ -z "$client" ]]; then + echo "CLOUDDNS_CLIENT (id) parameter not set" + exit 1 +fi + +# Login to clouddns to get accessToken +resp=$(curl --silent -X POST -H 'Content-Type: application/json' "$LOGIN_API" \ + --data "{\"email\": \"$email\", \"password\": \"$password\"}") +re='"accessToken":"([^,]*)",' # Match access token +if [[ "${resp// }" =~ $re ]]; then + access_token="${BASH_REMATCH[1]}" +fi +if [[ -z "$access_token" ]]; then + echo 'Could not get access token; check your credentials' + exit 3 +fi +curl_params=( -H "Authorization: Bearer $access_token" -H 'Content-Type: application/json' ) + +# Get main domain +resp=$(curl --silent "${curl_params[@]}" -X POST "$API/domain/search" \ + --data "{\"search\": [{\"name\": \"clientId\", \"operator\": \"eq\", \"value\": \"$client\"}]}") +domain_slice="$fulldomain" +while [[ -z "$domain_root" ]]; do + if [[ "${resp// }" =~ domainName\":\"$domain_slice ]]; then + domain_root="$domain_slice" + _debug domain_root "$domain_root" + fi + domain_slice="${domain_slice#[^\.]*.}" +done + +# Get domain id +resp=$(curl --silent "${curl_params[@]}" -X POST "$API/domain/search" \ + --data "{\"search\": [{\"name\": \"clientId\", \"operator\": \"eq\", \"value\": \"$client\"}, {\"name\": \"domainName\", \"operator\": \"eq\", \"value\": \"$domain_root.\"}]}") +re='domainType":"[^"]*","id":"([^,]*)",' # Match domain id +if [[ "${resp//[$'\t\r\n ']}" =~ $re ]]; then + domain_id="${BASH_REMATCH[1]}" +fi + +if [[ -z "$domain_id" ]]; then + echo 'Domain name not found on your CloudDNS account' + exit 3 +fi + +# Add challenge record +txt_record="_acme-challenge.$fulldomain." +resp=$(curl --silent "${curl_params[@]}" -X POST "$API/record-txt" \ + --data "{\"type\":\"TXT\",\"name\":\"$txt_record\",\"value\":\"$token\",\"domainId\":\"$domain_id\"}") + +# If adding record failed (error:) then print error message +if [[ "${resp// }" == *'"error"'* ]]; then + if [[ "${resp// }" == *'"code":4136'* ]]; then + echo "DNS challenge token already exists" + exit + fi + re='"message":"([^"]+)"' + if [[ "$resp" =~ $re ]]; then + echo "Error: DNS challenge not added: ${BASH_REMATCH[1]}" + exit 3 + else + echo "Error: DNS challenge not added: unknown error - ${resp}" + exit 3 + fi +fi + +# Publish challenge record +resp=$(curl --silent "${curl_params[@]}" -X PUT "$API/domain/$domain_id/publish" \ + --data "{\"soaTtl\":300}") diff --git a/dns_scripts/dns_del_clouddns b/dns_scripts/dns_del_clouddns new file mode 100755 index 0000000..0b25121 --- /dev/null +++ b/dns_scripts/dns_del_clouddns @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Need to add your email address and API key to clouddns below or set as env variables +email=${CLOUDDNS_EMAIL:-''} +password=${CLOUDDNS_PASSWORD:-''} +client=${CLOUDDNS_CLIENT:-''} + +# This script adds a token to clouddns DNS for the ACME challenge +# usage dns_add_clouddns "domain name" "token" +# return codes are; +# 0 - success +# 1 - error in input +# 2 - error within internal processing +# 3 - error in result ( domain not found in clouddns etc) + +fulldomain="${1}" +token="${2}" +API='https://admin.vshosting.cloud/clouddns' +LOGIN_API='https://admin.vshosting.cloud/api/public/auth/login' + +# Check initial parameters +if [[ -z "$fulldomain" ]]; then + echo "DNS script requires full domain name as first parameter" + exit 1 +fi +if [[ -z "$token" ]]; then + echo "DNS script requires challenge token as second parameter" + exit 1 +fi +if [[ -z "$email" ]]; then + echo "CLOUDDNS_EMAIL (email) parameter not set" + exit 1 +fi +if [[ -z "$password" ]]; then + echo "CLOUDDNS_PASSWORD (password) parameter not set" + exit 1 +fi +if [[ -z "$client" ]]; then + echo "CLOUDDNS_CLIENT (id) parameter not set" + exit 1 +fi + +# Login to clouddns to get accessToken +resp=$(curl --silent -X POST -H 'Content-Type: application/json' "$LOGIN_API" \ + --data "{\"email\": \"$email\", \"password\": \"$password\"}") +re='"accessToken":"([^,]*)",' # Match access token +if [[ "${resp// }" =~ $re ]]; then + access_token="${BASH_REMATCH[1]}" +fi +if [[ -z "$access_token" ]]; then + echo 'Could not get access token; check your credentials' + exit 3 +fi +curl_params=( -H "Authorization: Bearer $access_token" -H 'Content-Type: application/json' ) + +# Get main domain and challenge record +resp=$(curl --silent "${curl_params[@]}" -X POST "$API/domain/search" \ + --data "{\"search\": [{\"name\": \"clientId\", \"operator\": \"eq\", \"value\": \"$client\"}]}") +domain_slice="$fulldomain" +while [[ -z "$domain_root" ]]; do + if [[ "${resp// }" =~ domainName\":\"$domain_slice ]]; then + domain_root="$domain_slice" + _debug domain_root "$domain_root" + fi + domain_slice="${domain_slice#[^\.]*.}" +done +txt_record="_acme-challenge.$fulldomain." + +# Get domain id +curl_domainid_body="{\"search\": [{\"name\": \"clientId\", \"operator\": \"eq\", \"value\": \"$client\"}, {\"name\": \"domainName\", \"operator\": \"eq\", \"value\": \"$domain_root.\"}]}" +resp=$(curl --silent "${curl_params[@]}" -X POST -d "$curl_domainid_body" "$API/domain/search") +re='domainType":"[^"]*","id":"([^,]*)",' # Find result section +if [[ "${resp//[$'\t\r\n ']}" =~ $re ]]; then + domain_id="${BASH_REMATCH[1]}" +fi + +if [[ -z "$domain_id" ]]; then + echo 'Domain name not found on your CloudDNS account' + exit 3 +fi + +# Get challenge record ID +resp=$(curl --silent "${curl_params[@]}" -X GET "$API/domain/$domain_id" ) +re="\"lastDomainRecordList\".*\"id\":\"([^,]*)\"[^}]*\"name\":\"$txt_record\"," # Match domain id +if [[ "${resp//[$'\t\r\n ']}" =~ $re ]]; then + record_id="${BASH_REMATCH[1]}" +fi + +if [[ -z "$record_id" ]]; then + echo 'Challenge record does not exist' + exit 3 +fi + +# Remove challenge record +resp=$(curl --silent "${curl_params[@]}" -X DELETE "$API/record/$record_id") + +# If removing record failed (error:) then print error message +if [[ "${resp// }" == *'"error"'* ]]; then + re='"message":"([^"]+)"' + if [[ "$resp" =~ $re ]]; then + echo "Error: DNS challenge not removed: ${BASH_REMATCH[1]}" + exit 3 + else + echo "Error: DNS challenge not removed: unknown error - ${resp}" + exit 3 + fi +fi + +# Publish challenge record deletion +resp=$(curl --silent "${curl_params[@]}" -X PUT "$API/domain/$domain_id/publish" \ + --data "{\"soaTtl\":300}") diff --git a/dns_scripts/dns_freedns.sh b/dns_scripts/dns_freedns.sh new file mode 100644 index 0000000..a6571e1 --- /dev/null +++ b/dns_scripts/dns_freedns.sh @@ -0,0 +1,702 @@ +#!/usr/bin/env sh + +#This file name is "dns_freedns.sh" +#So, here must be a method dns_freedns_add() +#Which will be called by acme.sh to add the txt record to your api system. +#returns 0 means success, otherwise error. +# +#Author: David Kerr +#Report Bugs here: https://github.com/dkerr64/acme.sh +#or here... https://github.com/Neilpang/acme.sh/issues/2305 +# +######## Public functions ##################### + +# Export FreeDNS userid and password in following variables... +# FREEDNS_User=username +# FREEDNS_Password=password +# login cookie is saved in acme account config file so userid / pw +# need to be set only when changed. + +#Usage: dns_freedns_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_freedns_add() { + fulldomain="_acme-challenge.$1" + txtvalue="$2" + FREEDNS_COOKIE="$(cat $(dirname "$(readlink -f "$0")")/freednscookie.dat)" + + echo "Info: Add TXT record using FreeDNS" + #echo "Debug: fulldomain: $fulldomain" + #echo "Debug: txtvalue: $txtvalue" + + if [ -z "$FREEDNS_User" ] || [ -z "$FREEDNS_Password" ]; then + FREEDNS_User="" + FREEDNS_Password="" + if [ -z "$FREEDNS_COOKIE" ]; then + echo "ERROR: You did not specify the FreeDNS username and password yet." + echo "ERROR: Please export as FREEDNS_User / FREEDNS_Password and try again." + return 1 + fi + using_cached_cookies="true" + else + FREEDNS_COOKIE="$(_freedns_login "$FREEDNS_User" "$FREEDNS_Password")" + if [ -z "$FREEDNS_COOKIE" ]; then + return 1 + fi + using_cached_cookies="false" + fi + + #echo "Debug: FreeDNS login cookies: $FREEDNS_COOKIE (cached = $using_cached_cookies)" + + echo "$FREEDNS_COOKIE">$(dirname "$(readlink -f "$0")")/freednscookie.dat + + # We may have to cycle through the domain name to find the + # TLD that we own... + i=1 + wmax="$(echo "$fulldomain" | tr '.' ' ' | wc -w)" + while [ "$i" -lt "$wmax" ]; do + # split our full domain name into two parts... + sub_domain="$(echo "$fulldomain" | cut -d. -f -"$i")" + i="$(_math "$i" + 1)" + top_domain="$(echo "$fulldomain" | cut -d. -f "$i"-100)" + #echo "Debug: sub_domain: $sub_domain" + #echo "Debug: top_domain: $top_domain" + + DNSdomainid="$(_freedns_domain_id "$top_domain")" + if [ "$?" = "0" ]; then + echo "Info:Domain $top_domain found at FreeDNS, domain_id $DNSdomainid" + break + else + echo "Info:Domain $top_domain not found at FreeDNS, try with next level of TLD" + fi + done + + if [ -z "$DNSdomainid" ]; then + # If domain ID is empty then something went wrong (top level + # domain not found at FreeDNS). + echo "ERROR: Domain $top_domain not found at FreeDNS" + return 1 + fi + + # Add in new TXT record with the value provided + #echo "Debug: Adding TXT record for $fulldomain, $txtvalue" + _freedns_add_txt_record "$FREEDNS_COOKIE" "$DNSdomainid" "$sub_domain" "$txtvalue" + return $? +} + +#Usage: fulldomain txtvalue +#Remove the txt record after validation. +dns_freedns_rm() { + fulldomain="_acme-challenge.$1" + txtvalue="$2" + + echo "Info:Delete TXT record using FreeDNS" + #echo "Debug: fulldomain: $fulldomain" + #echo "Debug: txtvalue: $txtvalue" + + # Need to read cookie from conf file again in case new value set + # during login to FreeDNS when TXT record was created. + FREEDNS_COOKIE="$(cat $(dirname "$(readlink -f "$0")")/freednscookie.dat)" + #echo "Debug: FreeDNS login cookies: $FREEDNS_COOKIE" + + TXTdataid="$(_freedns_data_id "$fulldomain" "TXT")" + if [ "$?" != "0" ]; then + echo "Info:Cannot delete TXT record for $fulldomain, record does not exist at FreeDNS" + return 1 + fi + #echo "Debug: Data ID's found, $TXTdataid" + + # now we have one (or more) TXT record data ID's. Load the page + # for that record and search for the record txt value. If match + # then we can delete it. + lines="$(echo "$TXTdataid" | wc -l)" + #echo "Debug: Found $lines TXT data records for $fulldomain" + i=0 + while [ "$i" -lt "$lines" ]; do + i="$(_math "$i" + 1)" + dataid="$(echo "$TXTdataid" | sed -n "${i}p")" + #echo "Debug: $dataid" + + htmlpage="$(_freedns_retrieve_data_page "$FREEDNS_COOKIE" "$dataid")" + if [ "$?" != "0" ]; then + if [ "$using_cached_cookies" = "true" ]; then + echo "ERROR: Has your FreeDNS username and password changed? If so..." + echo "ERROR: Please export as FREEDNS_User / FREEDNS_Password and try again." + fi + return 1 + fi + + echo "$htmlpage" | grep "value=\""$txtvalue"\"" >/dev/null + if [ "$?" = "0" ]; then + # Found a match... delete the record and return + echo "Info:Deleting TXT record for $fulldomain, $txtvalue" + _freedns_delete_txt_record "$FREEDNS_COOKIE" "$dataid" + return $? + fi + done + + # If we get this far we did not find a match + # Not necessarily an error, but log anyway. + echo "Info:Cannot delete TXT record for $fulldomain, $txtvalue. Does not exist at FreeDNS" + return 0 +} + +#################### Private functions below ################################## + +# usage: _freedns_login username password +# print string "cookie=value" etc. +# returns 0 success +_freedns_login() { + export _H1="Accept-Language:en-US" + username="$1" + password="$2" + url="https://freedns.afraid.org/zc.php?step=2" + + #echo "Debug: Login to FreeDNS as user $username" + data="username=$(printf '%s' "$username" | _url_encode)&password=$(printf '%s' "$password" | _url_encode)&submit=Login&action=auth" + #echo "$data" + + if [ -z "$HTTP_HEADER" ] || ! touch "$HTTP_HEADER"; then + HTTP_HEADER="$(_mktemp)" + fi + htmlpage="$(curl -L --silent --dump-header $HTTP_HEADER -X POST -H "$_H1" -H "$_H2" --data "$data" "$url")" + + if [ "$?" != "0" ]; then + echo "ERROR: FreeDNS login failed for user $username bad RC from _post" + return 1 + fi + + cookies="$(grep -i '^Set-Cookie.*dns_cookie.*$' "$HTTP_HEADER" | _head_n 1 | tr -d "\r\n" | cut -d " " -f 2)" + + # if cookies is not empty then logon successful + if [ -z "$cookies" ]; then + #echo "Debug3: htmlpage: $htmlpage" + echo "ERROR: FreeDNS login failed for user $username. Check $HTTP_HEADER file" + return 1 + fi + + printf "%s" "$cookies" + return 0 +} + +# usage _freedns_retrieve_subdomain_page login_cookies +# echo page retrieved (html) +# returns 0 success +_freedns_retrieve_subdomain_page() { + export _H1="Cookie:$1" + export _H2="Accept-Language:en-US" + url="https://freedns.afraid.org/subdomain/" + + #echo "Debug: Retrieve subdomain page from FreeDNS" + + htmlpage="$(curl -L --silent -H "$_H1" -H "$_H2" "$url")" + + if [ "$?" != "0" ]; then + echo "ERROR: FreeDNS retrieve subdomains failed bad RC from _get" + return 1 + elif [ -z "$htmlpage" ]; then + echo "ERROR: FreeDNS returned empty subdomain page" + return 1 + fi + + #echo "Debug3: htmlpage: $htmlpage" + + printf "%s" "$htmlpage" + return 0 +} + +# usage _freedns_retrieve_data_page login_cookies data_id +# echo page retrieved (html) +# returns 0 success +_freedns_retrieve_data_page() { + export _H1="Cookie:$1" + export _H2="Accept-Language:en-US" + data_id="$2" + url="https://freedns.afraid.org/subdomain/edit.php?data_id=$2" + + #echo "Debug: Retrieve data page for ID $data_id from FreeDNS" + + htmlpage="$(curl -L --silent -H "$_H1" -H "$_H2" "$url")" + + if [ "$?" != "0" ]; then + echo "ERROR: FreeDNS retrieve data page failed bad RC from _get" + return 1 + elif [ -z "$htmlpage" ]; then + echo "ERROR: FreeDNS returned empty data page" + return 1 + fi + + #echo "Debug3: htmlpage: $htmlpage" + + printf "%s" "$htmlpage" + return 0 +} + +# usage _freedns_add_txt_record login_cookies domain_id subdomain value +# returns 0 success +_freedns_add_txt_record() { + export _H1="Cookie:$1" + export _H2="Accept-Language:en-US" + domain_id="$2" + subdomain="$3" + value="$(printf '%s' "$4" | _url_encode)" + url="https://freedns.afraid.org/subdomain/save.php?step=2" + + if [ -z "$HTTP_HEADER" ] || ! touch "$HTTP_HEADER"; then + HTTP_HEADER="$(_mktemp)" + fi + htmlpage="$(curl -L --silent --dump-header $HTTP_HEADER -X POST -H "$_H1" -H "$_H2" --data "type=TXT&domain_id=$domain_id&subdomain=$subdomain&address=%22$value%22&send=Save%21" "$url")" + + if [ "$?" != "0" ]; then + echo "ERROR: FreeDNS failed to add TXT record for $subdomain bad RC from _post" + return 1 + elif ! grep "200 OK" "$HTTP_HEADER" >/dev/null; then + #echo "Debug3: htmlpage: $(cat $HTTP_HEADER)" + echo "ERROR: FreeDNS failed to add TXT record for $subdomain. Check $HTTP_HEADER file" + return 1 + elif _contains "$htmlpage" "security code was incorrect"; then + #echo "Debug3: htmlpage: $htmlpage" + echo "ERROR: FreeDNS failed to add TXT record for $subdomain as FreeDNS requested security code" + echo "ERROR: Note that you cannot use automatic DNS validation for FreeDNS public domains" + return 1 + fi + + #echo "Debug3: htmlpage: $htmlpage" + echo "Info:Added acme challenge TXT record for $fulldomain at FreeDNS" + return 0 +} + +# usage _freedns_delete_txt_record login_cookies data_id +# returns 0 success +_freedns_delete_txt_record() { + export _H1="Cookie:$1" + export _H2="Accept-Language:en-US" + data_id="$2" + url="https://freedns.afraid.org/subdomain/delete2.php" + + htmlheader="$(curl -L --silent -I -H "$_H1" -H "$_H2" "$url?data_id%5B%5D=$data_id&submit=delete+selected")" + + if [ "$?" != "0" ]; then + echo "ERROR: FreeDNS failed to delete TXT record for $data_id bad RC from _get" + return 1 + elif ! _contains "$htmlheader" "200 OK"; then + #echo "Debug2: htmlheader: $htmlheader" + echo "ERROR: FreeDNS failed to delete TXT record $data_id" + return 1 + fi + + echo "Info:Deleted acme challenge TXT record for $fulldomain at FreeDNS" + return 0 +} + +# usage _freedns_domain_id domain_name +# echo the domain_id if found +# return 0 success +_freedns_domain_id() { + # Start by escaping the dots in the domain name + search_domain="$(echo "$1" | sed 's/\./\\./g')" + + # Sometimes FreeDNS does not return the subdomain page but rather + # returns a page regarding becoming a premium member. This usually + # happens after a period of inactivity. Immediately trying again + # returns the correct subdomain page. So, we will try twice to + # load the page and obtain our domain ID + attempts=2 + while [ "$attempts" -gt "0" ]; do + attempts="$(_math "$attempts" - 1)" + + htmlpage="$(_freedns_retrieve_subdomain_page "$FREEDNS_COOKIE")" + if [ "$?" != "0" ]; then + if [ "$using_cached_cookies" = "true" ]; then + echo "ERROR: Has your FreeDNS username and password changed? If so..." + echo "ERROR: Please export as FREEDNS_User / FREEDNS_Password and try again." + fi + return 1 + fi + + domain_id="$(echo "$htmlpage" | tr -d " \t\r\n\v\f" | sed 's//@/g' | tr '@' '\n' \ + | grep "$search_domain\|$search_domain(.*)" \ + | sed -n 's/.*\(edit\.php?edit_domain_id=[0-9a-zA-Z]*\).*/\1/p' \ + | cut -d = -f 2)" + # The above beauty extracts domain ID from the html page... + # strip out all blank space and new lines. Then insert newlines + # before each table row + # search for the domain within each row (which may or may not have + # a text string in brackets (.*) after it. + # And finally extract the domain ID. + if [ -n "$domain_id" ]; then + printf "%s" "$domain_id" + return 0 + fi + #echo "Debug:Domain $search_domain not found. Retry loading subdomain page ($attempts attempts remaining)" + done + #echo "Debug:Domain $search_domain not found after retry" + return 1 +} + +# usage _freedns_data_id domain_name record_type +# echo the data_id(s) if found +# return 0 success +_freedns_data_id() { + # Start by escaping the dots in the domain name + search_domain="$(echo "$1" | sed 's/\./\\./g')" + record_type="$2" + + # Sometimes FreeDNS does not return the subdomain page but rather + # returns a page regarding becoming a premium member. This usually + # happens after a period of inactivity. Immediately trying again + # returns the correct subdomain page. So, we will try twice to + # load the page and obtain our domain ID + attempts=2 + while [ "$attempts" -gt "0" ]; do + attempts="$(_math "$attempts" - 1)" + + htmlpage="$(_freedns_retrieve_subdomain_page "$FREEDNS_COOKIE")" + if [ "$?" != "0" ]; then + if [ "$using_cached_cookies" = "true" ]; then + echo "ERROR: Has your FreeDNS username and password changed? If so..." + echo "ERROR: Please export as FREEDNS_User / FREEDNS_Password and try again." + fi + return 1 + fi + + data_id="$(echo "$htmlpage" | tr -d " \t\r\n\v\f" | sed 's//@/g' | tr '@' '\n' \ + | grep "$record_type" \ + | grep "$search_domain" \ + | sed -n 's/.*\(edit\.php?data_id=[0-9a-zA-Z]*\).*/\1/p' \ + | cut -d = -f 2)" + # The above beauty extracts data ID from the html page... + # strip out all blank space and new lines. Then insert newlines + # before each table row + # search for the record type withing each row (e.g. TXT) + # search for the domain within each row (which is within a + # anchor. And finally extract the domain ID. + if [ -n "$data_id" ]; then + printf "%s" "$data_id" + return 0 + fi + #echo "Debug:Domain $search_domain not found. Retry loading subdomain page ($attempts attempts remaining)" + done + #echo "Debug:Domain $search_domain not found after retry" + return 1 +} + +#### BEGIN things shamefully ripped from https://github.com/Neilpang/acme.sh/blob/master/acme.sh + +#_ascii_hex str +#this can only process ascii chars, should only be used when od command is missing as a backup way. +_ascii_hex() { + _debug2 "Using _ascii_hex" + _str="$1" + _str_len=${#_str} + _h_i=1 + while [ "$_h_i" -le "$_str_len" ]; do + _str_c="$(printf "%s" "$_str" | cut -c "$_h_i")" + printf " %02x" "'$_str_c" + _h_i="$(_math "$_h_i" + 1)" + done +} + +#stdin output hexstr splited by one space +#input:"abc" +#output: " 61 62 63" +_hex_dump() { + if _exists od; then + od -A n -v -t x1 | tr -s " " | sed 's/ $//' | tr -d "\r\t\n" + elif _exists hexdump; then + hexdump -v -e '/1 ""' -e '/1 " %02x" ""' + elif _exists xxd; then + xxd -ps -c 20 -i | sed "s/ 0x/ /g" | tr -d ",\n" | tr -s " " + else + str=$(cat) + _ascii_hex "$str" + fi +} + +#url encode, no-preserved chars +#A B C D E F G H I J K L M N O P Q R S T U V W X Y Z +#41 42 43 44 45 46 47 48 49 4a 4b 4c 4d 4e 4f 50 51 52 53 54 55 56 57 58 59 5a + +#a b c d e f g h i j k l m n o p q r s t u v w x y z +#61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 70 71 72 73 74 75 76 77 78 79 7a + +#0 1 2 3 4 5 6 7 8 9 - _ . ~ +#30 31 32 33 34 35 36 37 38 39 2d 5f 2e 7e + +#stdin stdout +_url_encode() { + _hex_str=$(_hex_dump) + for _hex_code in $_hex_str; do + #upper case + case "${_hex_code}" in + "41") + printf "%s" "A" + ;; + "42") + printf "%s" "B" + ;; + "43") + printf "%s" "C" + ;; + "44") + printf "%s" "D" + ;; + "45") + printf "%s" "E" + ;; + "46") + printf "%s" "F" + ;; + "47") + printf "%s" "G" + ;; + "48") + printf "%s" "H" + ;; + "49") + printf "%s" "I" + ;; + "4a") + printf "%s" "J" + ;; + "4b") + printf "%s" "K" + ;; + "4c") + printf "%s" "L" + ;; + "4d") + printf "%s" "M" + ;; + "4e") + printf "%s" "N" + ;; + "4f") + printf "%s" "O" + ;; + "50") + printf "%s" "P" + ;; + "51") + printf "%s" "Q" + ;; + "52") + printf "%s" "R" + ;; + "53") + printf "%s" "S" + ;; + "54") + printf "%s" "T" + ;; + "55") + printf "%s" "U" + ;; + "56") + printf "%s" "V" + ;; + "57") + printf "%s" "W" + ;; + "58") + printf "%s" "X" + ;; + "59") + printf "%s" "Y" + ;; + "5a") + printf "%s" "Z" + ;; + + #lower case + "61") + printf "%s" "a" + ;; + "62") + printf "%s" "b" + ;; + "63") + printf "%s" "c" + ;; + "64") + printf "%s" "d" + ;; + "65") + printf "%s" "e" + ;; + "66") + printf "%s" "f" + ;; + "67") + printf "%s" "g" + ;; + "68") + printf "%s" "h" + ;; + "69") + printf "%s" "i" + ;; + "6a") + printf "%s" "j" + ;; + "6b") + printf "%s" "k" + ;; + "6c") + printf "%s" "l" + ;; + "6d") + printf "%s" "m" + ;; + "6e") + printf "%s" "n" + ;; + "6f") + printf "%s" "o" + ;; + "70") + printf "%s" "p" + ;; + "71") + printf "%s" "q" + ;; + "72") + printf "%s" "r" + ;; + "73") + printf "%s" "s" + ;; + "74") + printf "%s" "t" + ;; + "75") + printf "%s" "u" + ;; + "76") + printf "%s" "v" + ;; + "77") + printf "%s" "w" + ;; + "78") + printf "%s" "x" + ;; + "79") + printf "%s" "y" + ;; + "7a") + printf "%s" "z" + ;; + #numbers + "30") + printf "%s" "0" + ;; + "31") + printf "%s" "1" + ;; + "32") + printf "%s" "2" + ;; + "33") + printf "%s" "3" + ;; + "34") + printf "%s" "4" + ;; + "35") + printf "%s" "5" + ;; + "36") + printf "%s" "6" + ;; + "37") + printf "%s" "7" + ;; + "38") + printf "%s" "8" + ;; + "39") + printf "%s" "9" + ;; + "2d") + printf "%s" "-" + ;; + "5f") + printf "%s" "_" + ;; + "2e") + printf "%s" "." + ;; + "7e") + printf "%s" "~" + ;; + #other hex + *) + printf '%%%s' "$_hex_code" + ;; + esac + done +} + +_exists() { + cmd="$1" + if [ -z "$cmd" ]; then + _usage "Usage: _exists cmd" + return 1 + fi + + if eval type type >/dev/null 2>&1; then + eval type "$cmd" >/dev/null 2>&1 + elif command >/dev/null 2>&1; then + command -v "$cmd" >/dev/null 2>&1 + else + which "$cmd" >/dev/null 2>&1 + fi + ret="$?" + #echo "Debug3: $cmd exists=$ret" + return $ret +} + +_head_n() { + head -n "$1" +} + +_mktemp() { + if _exists mktemp; then + if mktemp 2>/dev/null; then + return 0 + elif _contains "$(mktemp 2>&1)" "-t prefix" && mktemp -t "$PROJECT_NAME" 2>/dev/null; then + #for Mac osx + return 0 + fi + fi + if [ -d "/tmp" ]; then + echo "/tmp/${PROJECT_NAME}wefADf24sf.$(_time).tmp" + return 0 + elif [ "$LE_TEMP_DIR" ] && mkdir -p "$LE_TEMP_DIR"; then + echo "/$LE_TEMP_DIR/wefADf24sf.$(_time).tmp" + return 0 + fi + _err "Can not create temp file." +} + +#a + b +_math() { + _m_opts="$@" + printf "%s" "$(($_m_opts))" +} + +_contains() { + _str="$1" + _sub="$2" + echo "$_str" | grep -- "$_sub" >/dev/null 2>&1 +} + +##Now actually do something with that function +case "$1" in + + add) + dns_freedns_add $2 $3 + ;; + rm) + dns_freedns_rm $2 $3 + ;; +esac diff --git a/docker-compose.yml b/docker-compose.yml index cbe52e2..97bb1e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,23 @@ services: - i.centos6.getssl.test - j.centos6.getssl.test - k.centos6.getssl.test + getssl-ubuntu18-no-gawk: + build: + context: . + dockerfile: test/Dockerfile-ubuntu18-no-gawk + container_name: getssl-ubuntu18-no-gawk + volumes: + - .:/getssl + environment: + GETSSL_HOST: ubuntu18-no-gawk.getssl.test + GETSSL_IP: 10.30.50.6 + NGINX_CONFIG: /etc/nginx/sites-enabled/default + TEST_AWK: "yes" + networks: + acmenet: + ipv4_address: 10.30.50.6 + aliases: + - ubuntu18-no-gawk.getssl.test networks: acmenet: diff --git a/getssl b/getssl index dd07a94..9da3dbf 100755 --- a/getssl +++ b/getssl @@ -15,6 +15,8 @@ # For usage, run "getssl -h" or see https://github.com/srvrco/getssl +# ACMEv2 process is documented at https://tools.ietf.org/html/rfc8555#section-7.4 + # Revision history: # 2016-01-08 Created (v0.1) # 2016-01-11 type correction and upload to github (v0.2) @@ -197,6 +199,12 @@ # 2020-01-08 Error and exit if rate limited, exit if curl returns nothing # 2020-01-10 Change domain and getssl templates to v2 (2.15) # 2020-01-17 #473 and #477 Don't use POST-as-GET when sending ready for challenge for ACMEv1 (2.16) +# 2020-01-22 #475 and #483 Fix grep regex for >9 subdomains in json_get +# 2020-01-24 Add support for CloudDNS +# 2020-01-24 allow file transfer using WebDAV over HTTPS +# 2020-01-26 Use urlbase64_decode() instead of base64 -d +# 2020-01-26 Fix "already verified" error for ACMEv2 +# 2020-01-29 Check awk new enough to support json_awk # ---------------------------------------------------------------------------------------- PROGNAME=${0##*/} @@ -250,8 +258,6 @@ _REVOKE=0 _UPGRADE=0 _UPGRADE_CHECK=1 _USE_DEBUG=0 -_INFO_COLOR="" -_RESET=$(tput sgr0) config_errors="false" LANG=C API=1 @@ -593,6 +599,20 @@ copy_file_to_location() { # copies a file, using scp, sftp or ftp if required. lcd $fromdir put $fromfile _EOF + elif [[ "${to:0:5}" == "davs:" ]] ; then + debug "using davs to copy the file from $from" + davsuser=$(echo "$to"| awk -F: '{print $2}') + davspass=$(echo "$to"| awk -F: '{print $3}') + davshost=$(echo "$to"| awk -F: '{print $4}') + davsport=$(echo "$to"| awk -F: '{print $5}') + davslocn=$(echo "$to"| awk -F: '{print $6}') + davsdirn=$(dirname "$davslocn") + davsfile=$(basename "$davslocn") + fromdir=$(dirname "$from") + fromfile=$(basename "$from") + debug "davs user=$davsuser - pass=$davspass - host=$davshost port=$davsport dir=$davsdirn file=$davsfile" + debug "from dir=$fromdir file=$fromfile" + curl -u "${davsuser}:${davspass}" -T "${fromdir}/${fromfile}" "https://${davshost}:${davsport}${davsdirn}/${davsfile}" else if ! mkdir -p "$(dirname "$to")" ; then error_exit "cannot create ACL directory $(basename "$to")" @@ -1279,9 +1299,7 @@ hex2bin() { # Remove spaces, add leading zero, escape as hex string ensuring no info() { # write out info as long as the quiet flag has not been set. if [[ ${_QUIET} -eq 0 ]]; then - echo -n "${_INFO_COLOR}" echo "$@" - echo -n "${_RESET}" fi } @@ -1621,7 +1639,6 @@ set_server_type() { # uses SERVER_TYPE to set REMOTE_PORT and REMOTE_EXTRA REMOTE_PORT=636 elif [[ ${SERVER_TYPE} =~ ^[0-9]+$ ]]; then REMOTE_PORT=${SERVER_TYPE} - REMOTE_EXTRA="CUSTOM-HTTP-PORT" else info "${DOMAIN}: unknown server type \"$SERVER_TYPE\" in SERVER_TYPE" config_errors=true @@ -1718,9 +1735,9 @@ send_signed_request() { # Sends a request to the ACME server, signed with your p fi responseHeaders=$(cat "$CURL_HEADER") - if [[ "$needbase64" && ${response##\()} != "{"* ]]; then - # response is in base64 too, decode (append = otherwise openssl truncates output) - response=$(echo "${response}=" | openssl base64 -d) + if [[ "$needbase64" && ${response##*()} != "{"* ]]; then + # response is in base64 too, decode + response=$(urlbase64_decode "$response") fi debug responseHeaders "$responseHeaders" @@ -1755,6 +1772,9 @@ send_signed_request() { # Sends a request to the ACME server, signed with your p error_exit "500 error from ACME server: $response" fi fi + if [[ "$code" -eq 429 ]]; then + error_exit "429 rate limited error from ACME server" + fi done if [[ $response == *"error:badNonce"* ]]; then debug "bad nonce" @@ -1839,6 +1859,18 @@ urlbase64() { # urlbase64: base64 encoded string with '+' replaced with '-' and openssl base64 -e | tr -d '\n\r' | os_esed -e 's:=*$::g' -e 'y:+/:-_:' } +# base64url decode +# From: https://gist.github.com/alvis/89007e96f7958f2686036d4276d28e47 +urlbase64_decode() { + INPUT=$1 # $(if [ -z "$1" ]; then echo -n $(cat -); else echo -n "$1"; fi) + MOD=$(($(echo -n "$INPUT" | wc -c) % 4)) + PADDING=$(if [ $MOD -eq 2 ]; then echo -n '=='; elif [ $MOD -eq 3 ]; then echo -n '=' ; fi) + echo -n "$INPUT$PADDING" | + sed s/-/+/g | + sed s/_/\\//g | + openssl base64 -d -A +} + usage() { # echos out the program usage echo "Usage: $PROGNAME [-h|--help] [-d|--debug] [-c|--create] [-f|--force] [-a|--all] [-q|--quiet]"\ "[-Q|--mute] [-u|--upgrade] [-k|--keep #] [-U|--nocheck] [-r|--revoke cert key] [-w working_dir] domain" @@ -1869,10 +1901,13 @@ write_domain_template() { # write out a template file for a domain. # If these start with ftp: then the next variables are ftpuserid:ftppassword:servername:ACL_location # These should be of the form "/path/to/your/website/folder/.well-known/acme-challenge" # where "/path/to/your/website/folder/" is the path, on your web server, to the web root for your domain. + # You can also user WebDAV over HTTPS as transport mechanism. To do so, start with davs: followed by username, + # password, host, port (explicitly needed even if using default port 443) and path on the server. #ACL=('/var/www/${DOMAIN}/web/.well-known/acme-challenge' # 'ssh:server5:/var/www/${DOMAIN}/web/.well-known/acme-challenge' # 'ssh:sshuserid@server5:/var/www/${DOMAIN}/web/.well-known/acme-challenge' - # 'ftp:ftpuserid:ftppassword:${DOMAIN}:/web/.well-known/acme-challenge') + # 'ftp:ftpuserid:ftppassword:${DOMAIN}:/web/.well-known/acme-challenge' + # 'davs:davsuserid:davspassword:{DOMAIN}:443:/web/.well-known/acme-challenge') #Set USE_SINGLE_ACL="true" to use a single ACL for all checks #USE_SINGLE_ACL="false" @@ -1957,8 +1992,7 @@ while [[ -n ${1+defined} ]]; do -h | --help) help_message; graceful_exit ;; -d | --debug) - _USE_DEBUG=1 - _INFO_COLOR=$(tput setaf 2);; + _USE_DEBUG=1 ;; -c | --create) _CREATE_CONFIG=1 ;; -f | --force) @@ -2109,8 +2143,8 @@ if [[ ${_CHECK_ALL} -eq 1 ]]; then if [[ ${_QUIET} -eq 1 ]]; then cmd="$cmd -q" fi - # check if $dir looks like a domain name (contains a period) - if [[ $(basename "$dir") == *.* ]]; then + # check if $dir is a directory with a getssl.cfg in it + if [[ -f "$dir/getssl.cfg" ]]; then cmd="$cmd -w $WORKING_DIR $(basename "$dir")" debug "CMD: $cmd" eval "$cmd" @@ -2227,6 +2261,14 @@ else fi debug "Using API v$API" +# Check if awk supports json_awk (required for ACMEv2) +if [[ $API -eq 2 ]]; then + json_awk_test=$(json_awk '{ "test": "1" }' 2>/dev/null) + if [[ "${json_awk_test}" == "" ]]; then + error_exit "Your version of awk does not work with json_awk (see http://github.com/step-/JSON.awk/issues/6), please install a newer version of mawk or gawk" + fi +fi + # if check_remote is true then connect and obtain the current certificate (if not forcing renewal) if [[ "${CHECK_REMOTE}" == "true" ]] && [[ $_FORCE_RENEW -eq 0 ]]; then debug "getting certificate for $DOMAIN from remote server" diff --git a/getssl.bak b/getssl.bak new file mode 100644 index 0000000..ea27441 --- /dev/null +++ b/getssl.bak @@ -0,0 +1,2610 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# getssl - Obtain SSL certificates from the letsencrypt.org ACME server + +# 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, either version 3 of the License, or +# (at your option) any later version. + +# 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 at for +# more details. + +# For usage, run "getssl -h" or see https://github.com/srvrco/getssl + +# Revision history: +# 2016-01-08 Created (v0.1) +# 2016-01-11 type correction and upload to github (v0.2) +# 2016-01-11 added import of any existing cert on -c option (v0.3) +# 2016-01-12 corrected formatting of imported certificate (v0.4) +# 2016-01-12 corrected error on removal of token in some instances (v0.5) +# 2016-01-18 corrected issue with removing tmp if run as root with the -c option (v0.6) +# 2016-01-18 added option to upload a single PEN file ( used by cpanel) (v0.7) +# 2016-01-23 added dns challenge option (v0.8) +# 2016-01-24 create the ACL directory if it does not exist. (v0.9) - dstosberg +# 2016-01-26 correcting a couple of small bugs and allow curl to follow redirects (v0.10) +# 2016-01-27 add a very basic openssl.cnf file if it doesn't exist and tidy code slightly (v0.11) +# 2016-01-28 Typo corrections, quoted file variables and fix bug on DNS_DEL_COMMAND (v0.12) +# 2016-01-28 changed DNS checks to use nslookup and allow hyphen in domain names (v0.13) +# 2016-01-29 Fix ssh-reload-command, extra waiting for DNS-challenge, +# 2016-01-29 add error_exit and cleanup help message (v0.14) +# 2016-01-29 added -a|--all option to renew all configured certificates (v0.15) +# 2016-01-29 added option for elliptic curve keys (v0.16) +# 2016-01-29 added server-type option to use and check cert validity from website (v0.17) +# 2016-01-30 added --quiet option for running in cron (v0.18) +# 2016-01-31 removed usage of xxd to make script more compatible across versions (v0.19) +# 2016-01-31 removed usage of base64 to make script more compatible across platforms (v0.20) +# 2016-01-31 added option to safe a full chain certificate (v0.21) +# 2016-02-01 commented code and added option for copying concatenated certs to file (v0.22) +# 2016-02-01 re-arrange flow for DNS-challenge, to reduce time taken (v0.23) +# 2016-02-04 added options for other server types (ldaps, or any port) and check_remote (v0.24) +# 2016-02-04 added short sleep following service restart before checking certs (v0.25) +# 2016-02-12 fix challenge token location when directory doesn't exist (v0.26) +# 2016-02-17 fix sed -E issue, and reduce length of renew check to 365 days for older systems (v0.27) +# 2016-04-05 Ensure DNS cleanup on error exit. (0.28) - pecigonzalo +# 2016-04-15 Remove NS Lookup of A record when using dns validation (0.29) - pecigonzalo +# 2016-04-17 Improving the wording in a couple of comments and info statements. (0.30) +# 2016-05-04 Improve check for if DNS_DEL_COMMAND is blank. (0.31) +# 2016-05-06 Setting umask to 077 for security of private keys etc. (0.32) +# 2016-05-20 update to reflect changes in staging ACME server json (0.33) +# 2016-05-20 tidying up checking of json following ACME changes. (0.34) +# 2016-05-21 added AUTH_DNS_SERVER to getssl.cfg as optional definition of authoritative DNS server (0.35) +# 2016-05-21 added DNS_WAIT to getssl.cfg as (default = 10 seconds as before) (0.36) +# 2016-05-21 added PUBLIC_DNS_SERVER option, for forcing use of an external DNS server (0.37) +# 2016-05-28 added FTP method of uploading tokens to remote server (blocked for certs as not secure) (0.38) +# 2016-05-28 added FTP method into the default config notes. (0.39) +# 2016-05-30 Add sftp with password to copy files (0.40) +# 2016-05-30 Add version check to see if there is a more recent version of getssl (0.41) +# 2016-05-30 Add [-u|--upgrade] option to automatically upgrade getssl (0.42) +# 2016-05-30 Added backup when auto-upgrading (0.43) +# 2016-05-30 Improvements to auto-upgrade (0.44) +# 2016-05-31 Improved comments - no structural changes +# 2016-05-31 After running for nearly 6 months, final testing prior to a 1.00 stable version. (0.90) +# 2016-06-01 Reorder functions alphabetically as part of code tidy. (0.91) +# 2016-06-03 Version 1.0 of code for release (1.00) +# 2016-06-09 bugfix of issue 44, and add success statement (ignoring quiet flag) (1.01) +# 2016-06-13 test return status of DNS_ADD_COMMAND and error_exit if a problem (hadleyrich) (1.02) +# 2016-06-13 bugfix of issue 45, problem with SERVER_TYPE when it's just a port number (1.03) +# 2016-06-13 bugfix issue 47 - DNS_DEL_COMMAND cleanup was run when not required. (1.04) +# 2016-06-15 add error checking on RELOAD_CMD (1.05) +# 2016-06-20 updated sed and date functions to run on MAC OS X (1.06) +# 2016-06-20 added CHALLENGE_CHECK_TYPE variable to allow checks direct on https rather than http (1.07) +# 2016-06-21 updated grep functions to run on MAC OS X (1.08) +# 2016-06-11 updated to enable running on windows with cygwin (1.09) +# 2016-07-02 Corrections to work with older slackware issue #56 (1.10) +# 2016-07-02 Updating help info re ACL in config file (1.11) +# 2016-07-04 adding DOMAIN_STORAGE as a variable to solve for issue #59 (1.12) +# 2016-07-05 updated order to better handle non-standard DOMAIN_STORAGE location (1.13) +# 2016-07-06 added additional comments about SANS in example template (1.14) +# 2016-07-07 check for duplicate domains in domain / SANS (1.15) +# 2016-07-08 modified to be used on older bash for issue #64 (1.16) +# 2016-07-11 added -w to -a option and comments in domain template (1.17) +# 2016-07-18 remove / regenerate csr when generating new private domain key (1.18) +# 2016-07-21 add output of combined private key and domain cert (1.19) +# 2016-07-21 updated typo (1.20) +# 2016-07-22 corrected issue in nslookup debug option - issue #74 (1.21) +# 2016-07-26 add more server-types based on openssl s_client (1.22) +# 2016-08-01 updated agreement for letsencrypt (1.23) +# 2016-08-02 updated agreement for letsencrypt to update automatically (1.24) +# 2016-08-03 improve messages on test of certificate installation (1.25) +# 2016-08-04 remove carriage return from agreement - issue #80 (1.26) +# 2016-08-04 set permissions for token folders - issue #81 (1.27) +# 2016-08-07 allow default chained file creation - issue #85 (1.28) +# 2016-08-07 use copy rather than move when archiving certs - issue #86 (1.29) +# 2016-08-07 enable use of a single ACL for all checks (if USE_SINGLE_ACL="true" (1.30) +# 2016-08-23 check for already validated domains (issue #93) - (1.31) +# 2016-08-23 updated already validated domains (1.32) +# 2016-08-23 included better force_renew and template for USE_SINGLE_ACL (1.33) +# 2016-08-23 enable insecure certificate on https token check #94 (1.34) +# 2016-08-23 export OPENSSL_CONF so it's used by all openssl commands (1.35) +# 2016-08-25 updated defaults for ACME agreement (1.36) +# 2016-09-04 correct issue #101 when some domains already validated (1.37) +# 2016-09-12 Checks if which is installed (1.38) +# 2016-09-13 Don't check for updates, if -U parameter has been given (1.39) +# 2016-09-17 Improved error messages from invalid certs (1.40) +# 2016-09-19 remove update check on recursive calls when using -a (1.41) +# 2016-09-21 changed shebang for portability (1.42) +# 2016-09-21 Included option to Deactivate an Authorization (1.43) +# 2016-09-22 retry on 500 error from ACME server (1.44) +# 2016-09-22 added additional checks and retry on 500 error from ACME server (1.45) +# 2016-09-24 merged in IPv6 support (1.46) +# 2016-09-27 added additional debug info issue #119 (1.47) +# 2016-09-27 removed IPv6 switch in favour of checking both IPv4 and IPv6 (1.48) +# 2016-09-28 Add -Q, or --mute, switch to mute notifications about successfully upgrading getssl (1.49) +# 2016-09-30 improved portability to work natively on FreeBSD, Slackware and Mac OS X (1.50) +# 2016-09-30 comment out PRIVATE_KEY_ALG from the domain template Issue #125 (1.51) +# 2016-10-03 check remote certificate for right domain before saving to local (1.52) +# 2016-10-04 allow existing CSR with domain name in subject (1.53) +# 2016-10-05 improved the check for CSR with domain in subject (1.54) +# 2016-10-06 prints update info on what was included in latest updates (1.55) +# 2016-10-06 when using -a flag, ignore folders in working directory which aren't domains (1.56) +# 2016-10-12 allow multiple tokens in DNS challenge (1.57) +# 2016-10-14 added CHECK_ALL_AUTH_DNS option to check all DNS servers, not just one primary server (1.58) +# 2016-10-14 added archive of chain and private key for each cert, and purge old archives (1.59) +# 2016-10-17 updated info comment on failed cert due to rate limits. (1.60) +# 2016-10-17 fix error messages when using 1.0.1e-fips (1.61) +# 2016-10-20 set secure permissions when generating account key (1.62) +# 2016-10-20 set permissions to 700 for getssl script during upgrade (1.63) +# 2016-10-20 add option to revoke a certificate (1.64) +# 2016-10-21 set revocation server default to acme-v01.api.letsencrypt.org (1.65) +# 2016-10-21 bug fix for revocation on different servers. (1.66) +# 2016-10-22 Tidy up archive code for certificates and reduce permissions for security +# 2016-10-22 Add EC signing for secp384r1 and secp521r1 (the latter not yet supported by Let's Encrypt +# 2016-10-22 Add option to create a new private key for every cert (REUSE_PRIVATE_KEY="true" by default) +# 2016-10-22 Combine EC signing, Private key reuse and archive permissions (1.67) +# 2016-10-25 added CHECK_REMOTE_WAIT option ( to pause before final remote check) +# 2016-10-25 Added EC account key support ( prime256v1, secp384r1 ) (1.68) +# 2016-10-25 Ignore DNS_EXTRA_WAIT if all domains already validated (issue #146) (1.69) +# 2016-10-25 Add option for dual ESA / EDSA certs (1.70) +# 2016-10-25 bug fix Issue #141 challenge error 400 (1.71) +# 2016-10-26 check content of key files, not just recreate if missing. +# 2016-10-26 Improvements on portability (1.72) +# 2016-10-26 Date formatting for busybox (1.73) +# 2016-10-27 bug fix - issue #157 not recognising EC keys on some versions of openssl (1.74) +# 2016-10-31 generate EC account keys and tidy code. +# 2016-10-31 fix warning message if cert doesn't exist (1.75) +# 2016-10-31 remove only specified DNS token #161 (1.76) +# 2016-11-03 Reduce long lines, and remove echo from update (1.77) +# 2016-11-05 added TOKEN_USER_ID (to set ownership of token files ) +# 2016-11-05 updated style to work with latest shellcheck (1.78) +# 2016-11-07 style updates +# 2016-11-07 bug fix DOMAIN_PEM_LOCATION starting with ./ #167 +# 2016-11-08 Fix for openssl 1.1.0 #166 (1.79) +# 2016-11-08 Add and comment optional sshuserid for ssh ACL (1.80) +# 2016-11-09 Add SKIP_HTTP_TOKEN_CHECK option (Issue #170) (1.81) +# 2016-11-13 bug fix DOMAIN_KEY_CERT generation (1.82) +# 2016-11-17 add PREVENT_NON_INTERACTIVE_RENEWAL option (1.83) +# 2016-12-03 add HTTP_TOKEN_CHECK_WAIT option (1.84) +# 2016-12-03 bugfix CSR renewal when no SANS and when using MINGW (1.85) +# 2016-12-16 create CSR_SUBJECT variable - Issue #193 +# 2016-12-16 added fullchain to archive (1.86) +# 2016-12-16 updated DOMAIN_PEM_LOCATION when using DUAL_RSA_ECDSA (1.87) +# 2016-12-19 allow user to ignore permission preservation with nfsv3 shares (1.88) +# 2016-12-19 bug fix for CA (1.89) +# 2016-12-19 included IGNORE_DIRECTORY_DOMAIN option (1.90) +# 2016-12-22 allow copying files to multiple locations (1.91) +# 2016-12-22 bug fix for copying tokens to multiple locations (1.92) +# 2016-12-23 tidy code - place default variables in alphabetical order. +# 2016-12-27 update checks to work with openssl in FIPS mode (1.93) +# 2016-12-28 fix leftover tmpfiles in upgrade routine (1.94) +# 2016-12-28 tidied up upgrade tmpfile handling (1.95) +# 2017-01-01 update comments +# 2017-01-01 create stable release 2.0 (2.00) +# 2017-01-02 Added option to limit number of old versions to keep (2.01) +# 2017-01-03 Created check_config function to list all obvious config issues (2.02) +# 2017-01-10 force renew if FORCE_RENEWAL file exists (2.03) +# 2017-01-12 added drill, dig or host as alternatives to nslookup (2.04) +# 2017-01-18 bugfix issue #227 - error deleting csr if doesn't exist +# 2017-01-18 issue #228 check private key and account key are different (2.05) +# 2017-01-21 issue #231 mingw bugfix and typos in debug messages (2.06) +# 2017-01-29 issue #232 use neutral locale for date formatting (2.07) +# 2017-01-30 issue #243 compatibility with bash 3.0 (2.08) +# 2017-01-30 issue #243 additional compatibility with bash 3.0 (2.09) +# 2017-02-18 add OCSP Must-Staple to the domain csr generation (2.10) +# 2018-01-04 updating to use the updated letsencrypt APIv2 +# 2019-09-30 issue #423 Use HTTP 1.1 as workaround atm (2.11) +# 2019-10-02 issue #425 Case insensitive processing of agreement url because of HTTP/2 (2.12) +# 2019-10-07 update DNS checks to allow use of CNAMEs (2.13) +# 2019-11-18 Rebased master onto APIv2 and added Content-Type: application/jose+json (2.14) +# 2019-11-20 #453 and #454 Add User-Agent to all curl requests +# 2019-11-22 #456 Fix shellcheck issues +# 2019-11-23 #459 Fix missing chain.crt +# 2019-12-18 #462 Use POST-as-GET for ACMEv2 endpoints +# 2020-01-07 #464 and #486 "json was blank" (change all curl request to use POST-as-GET) +# 2020-01-08 Error and exit if rate limited, exit if curl returns nothing +# 2020-01-10 Change domain and getssl templates to v2 (2.15) +# 2020-01-17 #473 and #477 Don't use POST-as-GET when sending ready for challenge for ACMEv1 (2.16) +# 2020-01-22 #475 and #483 Fix grep regex for >9 subdomains in json_get +# 2020-01-24 Add support for CloudDNS +# 2020-01-24 allow file transfer using WebDAV over HTTPS +# 2020-01-26 Use urlbase64_decode() instead of base64 -d +# 2020-01-26 Fix "already verified" error for ACMEv2 +# 2020-01-29 Check awk new enough to support json_awk +# ---------------------------------------------------------------------------------------- + +PROGNAME=${0##*/} +VERSION="2.16" + +# defaults +ACCOUNT_KEY_LENGTH=4096 +ACCOUNT_KEY_TYPE="rsa" +CA="https://acme-staging-v02.api.letsencrypt.org/directory" +CA_CERT_LOCATION="" +CHALLENGE_CHECK_TYPE="http" +CHECK_ALL_AUTH_DNS="false" +CHECK_REMOTE="true" +CHECK_REMOTE_WAIT=0 +CODE_LOCATION="https://raw.githubusercontent.com/srvrco/getssl/master/getssl" +CSR_SUBJECT="/" +CURL_USERAGENT="${PROGNAME}/${VERSION}" +DEACTIVATE_AUTH="false" +DEFAULT_REVOKE_CA="https://acme-v02.api.letsencrypt.org" +DNS_EXTRA_WAIT="" +DNS_WAIT=10 +DOMAIN_KEY_LENGTH=4096 +DUAL_RSA_ECDSA="false" +GETSSL_IGNORE_CP_PRESERVE="false" +HTTP_TOKEN_CHECK_WAIT=0 +IGNORE_DIRECTORY_DOMAIN="false" +ORIG_UMASK=$(umask) +PREVIOUSLY_VALIDATED="true" +PRIVATE_KEY_ALG="rsa" +PUBLIC_DNS_SERVER="" +RELOAD_CMD="" +RENEW_ALLOW="30" +REUSE_PRIVATE_KEY="true" +SERVER_TYPE="https" +SKIP_HTTP_TOKEN_CHECK="false" +SSLCONF="$(openssl version -d 2>/dev/null| cut -d\" -f2)/openssl.cnf" +OCSP_MUST_STAPLE="false" +TEMP_UPGRADE_FILE="" +TOKEN_USER_ID="" +USE_SINGLE_ACL="false" +VALIDATE_VIA_DNS="" +WORKING_DIR=~/.getssl +_CHECK_ALL=0 +_CREATE_CONFIG=0 +_FORCE_RENEW=0 +_KEEP_VERSIONS="" +_MUTE=0 +_QUIET=0 +_RECREATE_CSR=0 +_REVOKE=0 +_UPGRADE=0 +_UPGRADE_CHECK=1 +_USE_DEBUG=0 +config_errors="false" +LANG=C +API=1 + +# store copy of original command in case of upgrading script and re-running +ORIGCMD="$0 $*" + +# Define all functions (in alphabetical order) + +cert_archive() { # Archive certificate file by copying files to dated archive dir. + debug "creating an archive copy of current new certs" + date_time=$(date +%Y_%m_%d_%H_%M) + mkdir -p "${DOMAIN_DIR}/archive/${date_time}" + umask 077 + cp "$CERT_FILE" "${DOMAIN_DIR}/archive/${date_time}/${DOMAIN}.crt" + cp "$DOMAIN_DIR/${DOMAIN}.csr" "${DOMAIN_DIR}/archive/${date_time}/${DOMAIN}.csr" + cp "$DOMAIN_DIR/${DOMAIN}.key" "${DOMAIN_DIR}/archive/${date_time}/${DOMAIN}.key" + cp "$CA_CERT" "${DOMAIN_DIR}/archive/${date_time}/chain.crt" + cat "$CERT_FILE" "$CA_CERT" > "${DOMAIN_DIR}/archive/${date_time}/fullchain.crt" + if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then + cp "${CERT_FILE%.*}.ec.crt" "${DOMAIN_DIR}/archive/${date_time}/${DOMAIN}.ec.crt" + cp "$DOMAIN_DIR/${DOMAIN}.ec.csr" "${DOMAIN_DIR}/archive/${date_time}/${DOMAIN}.ec.csr" + cp "$DOMAIN_DIR/${DOMAIN}.ec.key" "${DOMAIN_DIR}/archive/${date_time}/${DOMAIN}.ec.key" + cp "${CA_CERT%.*}.ec.crt" "${DOMAIN_DIR}/archive/${date_time}/chain.ec.crt" + cat "${CERT_FILE%.*}.ec.crt" "${CA_CERT%.*}.ec.crt" > "${DOMAIN_DIR}/archive/${date_time}/fullchain.ec.crt" + fi + umask "$ORIG_UMASK" + debug "purging old GetSSL archives" + purge_archive "$DOMAIN_DIR" +} + +check_challenge_completion() { # checks with the ACME server if our challenge is OK + uri=$1 + domain=$2 + keyauthorization=$3 + + debug "sending request to ACME server saying we're ready for challenge" + + # check response from our request to perform challenge + if [[ $API -eq 1 ]]; then + send_signed_request "$uri" "{\"resource\": \"challenge\", \"keyAuthorization\": \"$keyauthorization\"}" + if [[ -n "$code" ]] && [[ ! "$code" == '202' ]] ; then + error_exit "$domain:Challenge error: $code" + fi + else # APIv2 + send_signed_request "$uri" "{}" + if [[ -n "$code" ]] && [[ ! "$code" == '200' ]] ; then + detail=$(echo "$response" | grep "detail" | awk -F\" '{print $4}') + error_exit "$domain:Challenge error: $code:Detail: $detail" + fi + fi + + # loop "forever" to keep checking for a response from the ACME server. + while true ; do + debug "checking if challenge is complete" + if [[ $API -eq 1 ]]; then + if ! get_cr "$uri" ; then + error_exit "$domain:Verify error:$code" + fi + else # APIv2 + send_signed_request "$uri" "" + fi + + status=$(json_get "$response" status) + + # If ACME response is valid, then break out of loop + if [[ "$status" == "valid" ]] ; then + info "Verified $domain" + break; + fi + + # if ACME response is that their check gave an invalid response, error exit + if [[ "$status" == "invalid" ]] ; then + error_exit "$domain:Verify error:$(echo "$response" | grep "detail" | awk -F' "' '{print $3}')" + fi + + # if ACME response is pending ( they haven't completed checks yet) then wait and try again. + if [[ "$status" == "pending" ]] ; then + info "Pending" + else + error_exit "$domain:Verify error:$(echo "$response" | grep "detail")" + fi + debug "sleep 5 secs before testing verify again" + sleep 5 + done + + if [[ "$DEACTIVATE_AUTH" == "true" ]]; then + deactivate_url=$(echo "$responseHeaders" | grep "^Link" | awk -F"[<>]" '{print $2}') + deactivate_url_list="$deactivate_url_list $deactivate_url" + debug "adding url to deactivate list - $deactivate_url" + fi +} + +check_config() { # check the config files for all obvious errors + debug "checking config" + + # check keys + case "$ACCOUNT_KEY_TYPE" in + rsa|prime256v1|secp384r1|secp521r1) + debug "checked ACCOUNT_KEY_TYPE " ;; + *) + info "${DOMAIN}: invalid ACCOUNT_KEY_TYPE - $ACCOUNT_KEY_TYPE" + config_errors=true ;; + esac + if [[ "$ACCOUNT_KEY" == "$DOMAIN_DIR/${DOMAIN}.key" ]]; then + info "${DOMAIN}: ACCOUNT_KEY and domain key ( $DOMAIN_DIR/${DOMAIN}.key ) must be different" + config_errors=true + fi + case "$PRIVATE_KEY_ALG" in + rsa|prime256v1|secp384r1|secp521r1) + debug "checked PRIVATE_KEY_ALG " ;; + *) + info "${DOMAIN}: invalid PRIVATE_KEY_ALG - $PRIVATE_KEY_ALG" + config_errors=true ;; + esac + if [[ "$DUAL_RSA_ECDSA" == "true" ]] && [[ "$PRIVATE_KEY_ALG" == "rsa" ]]; then + info "${DOMAIN}: PRIVATE_KEY_ALG not set to an EC type and DUAL_RSA_ECDSA=\"true\"" + config_errors=true + fi + + # get all domains + if [[ "$IGNORE_DIRECTORY_DOMAIN" == "true" ]]; then + alldomains=${SANS//,/ } + else + alldomains=$(echo "$DOMAIN,$SANS" | sed "s/,/ /g") + fi + if [[ -z "$alldomains" ]]; then + info "${DOMAIN}: no domains specified" + config_errors=true + fi + + if [[ $VALIDATE_VIA_DNS == "true" ]]; then # using dns-01 challenge + if [[ -z "$DNS_ADD_COMMAND" ]]; then + info "${DOMAIN}: DNS_ADD_COMMAND not defined (whilst VALIDATE_VIA_DNS=\"true\")" + config_errors=true + fi + if [[ -z "$DNS_DEL_COMMAND" ]]; then + info "${DOMAIN}: DNS_DEL_COMMAND not defined (whilst VALIDATE_VIA_DNS=\"true\")" + config_errors=true + fi + fi + + dn=0 + tmplist=$(mktemp 2>/dev/null || mktemp -t getssl) + for d in $alldomains; do # loop over domains (dn is domain number) + debug "checking domain $d" + if [[ "$(grep "^${d}$" "$tmplist")" = "$d" ]]; then + info "${DOMAIN}: $d appears to be duplicated in domain, SAN list" + config_errors=true + else + echo "$d" >> "$tmplist" + fi + + if [[ "$USE_SINGLE_ACL" == "true" ]]; then + DOMAIN_ACL="${ACL[0]}" + else + DOMAIN_ACL="${ACL[$dn]}" + fi + + if [[ $VALIDATE_VIA_DNS != "true" ]]; then # using http-01 challenge + if [[ -z "${DOMAIN_ACL}" ]]; then + info "${DOMAIN}: ACL location not specified for domain $d in $DOMAIN_DIR/getssl.cfg" + config_errors=true + fi + # check domain exist + if [[ "$DNS_CHECK_FUNC" == "drill" ]] || [[ "$DNS_CHECK_FUNC" == "dig" ]]; then + if [[ "$($DNS_CHECK_FUNC "${d}" |grep -c "${d}")" -ge 1 ]]; then + debug "found IP for ${d}" + else + info "${DOMAIN}: DNS lookup failed for ${d}" + config_errors=true + fi + elif [[ "$DNS_CHECK_FUNC" == "host" ]]; then + if [[ "$($DNS_CHECK_FUNC "${d}" |grep -c "^${d}")" -ge 1 ]]; then + debug "found IP for ${d}" + else + info "${DOMAIN}: DNS lookup failed for ${d}" + config_errors=true + fi + elif [[ "$(nslookup -query=AAAA "${d}"|grep -c "^${d}.*has AAAA address")" -ge 1 ]]; then + debug "found IPv6 record for ${d}" + elif [[ "$(nslookup "${d}"| grep -c ^Name)" -ge 1 ]]; then + debug "found IPv4 record for ${d}" + else + info "${DOMAIN}: DNS lookup failed for $d" + config_errors=true + fi + fi # end using http-01 challenge + ((dn++)) + done + + # tidy up + rm -f "$tmplist" + + if [[ "$config_errors" == "true" ]]; then + error_exit "${DOMAIN}: exiting due to config errors" + fi + debug "${DOMAIN}: check_config completed - all OK" +} + +check_getssl_upgrade() { # check if a more recent version of code is available available + TEMP_UPGRADE_FILE="$(mktemp 2>/dev/null || mktemp -t getssl)" + curl --user-agent "$CURL_USERAGENT" --silent "$CODE_LOCATION" --output "$TEMP_UPGRADE_FILE" + errcode=$? + if [[ $errcode -eq 60 ]]; then + error_exit "curl needs updating, your version does not support SNI (multiple SSL domains on a single IP)" + elif [[ $errcode -gt 0 ]]; then + error_exit "curl error : $errcode" + fi + latestversion=$(awk -F '"' '$1 == "VERSION=" {print $2}' "$TEMP_UPGRADE_FILE") + latestvdec=$(echo "$latestversion"| tr -d '.') + localvdec=$(echo "$VERSION"| tr -d '.' ) + debug "current code is version ${VERSION}" + debug "Most recent version is ${latestversion}" + # use a default of 0 for cases where the latest code has not been obtained. + if [[ "${latestvdec:-0}" -gt "$localvdec" ]]; then + if [[ ${_UPGRADE} -eq 1 ]]; then + install "$0" "${0}.v${VERSION}" + install -m 700 "$TEMP_UPGRADE_FILE" "$0" + if [[ ${_MUTE} -eq 0 ]]; then + echo "Updated getssl from v${VERSION} to v${latestversion}" + echo "these update notification can be turned off using the -Q option" + echo "" + echo "Updates are;" + awk "/\(${VERSION}\)$/ {s=1} s; /\(${latestversion}\)$/ {s=0}" "$TEMP_UPGRADE_FILE" | awk '{if(NR>1)print}' + echo "" + fi + if [[ -n "$_KEEP_VERSIONS" ]] && [[ "$_KEEP_VERSIONS" =~ ^[0-9]+$ ]]; then + # Obtain all locally stored old versions in getssl_versions + declare -a getssl_versions + shopt -s nullglob + for getssl_version in "$0".v*; do + getssl_versions[${#getssl_versions[@]}]="$getssl_version" + done + shopt -u nullglob + # Explicitly sort the getssl_versions array to make sure + shopt -s -o noglob + # shellcheck disable=SC2207 + IFS=$'\n' getssl_versions=($(sort <<< "${getssl_versions[*]}")) + shopt -u -o noglob + # Remove entries until given number of old versions to keep is reached + while [[ ${#getssl_versions[@]} -gt $_KEEP_VERSIONS ]]; do + debug "removing old version ${getssl_versions[0]}" + rm "${getssl_versions[0]}" + getssl_versions=("${getssl_versions[@]:1}") + done + fi + eval "$ORIGCMD" + graceful_exit + else + info "" + info "A more recent version (v${latestversion}) of getssl is available, please update" + info "the easiest way is to use the -u or --upgrade flag" + info "" + fi + fi +} + +clean_up() { # Perform pre-exit housekeeping + umask "$ORIG_UMASK" + if [[ $VALIDATE_VIA_DNS == "true" ]]; then + # Tidy up DNS entries if things failed part way though. + shopt -s nullglob + for dnsfile in "$TEMP_DIR"/dns_verify/*; do + # shellcheck source=/dev/null + . "$dnsfile" + debug "attempting to clean up DNS entry for $d" + eval "$DNS_DEL_COMMAND" "$d" "$auth_key" + done + shopt -u nullglob + fi + if [[ -n "$DOMAIN_DIR" ]]; then + rm -rf "${TEMP_DIR:?}" + fi + if [[ -n "$TEMP_UPGRADE_FILE" ]] && [[ -f "$TEMP_UPGRADE_FILE" ]]; then + rm -f "$TEMP_UPGRADE_FILE" + fi +} + +copy_file_to_location() { # copies a file, using scp, sftp or ftp if required. + cert=$1 # descriptive name, just used for display + from=$2 # current file location + to=$3 # location to move file to. + IFS=\; read -r -a copy_locations <<<"$3" + for to in "${copy_locations[@]}"; do + info "copying $cert to $to" + if [[ "${to:0:4}" == "ssh:" ]] ; then + debug "using scp scp -q $from ${to:4}" + if ! scp -q "$from" "${to:4}" >/dev/null 2>&1 ; then + error_exit "problem copying file to the server using scp. + scp $from ${to:4}" + fi + debug "userid $TOKEN_USER_ID" + if [[ "$cert" == "challenge token" ]] && [[ -n "$TOKEN_USER_ID" ]]; then + servername=$(echo "$to" | awk -F":" '{print $2}') + tofile=$(echo "$to" | awk -F":" '{print $3}') + debug "servername $servername" + debug "file $tofile" + # shellcheck disable=SC2029 + # shellcheck disable=SC2086 + ssh $SSH_OPTS "$servername" "chown $TOKEN_USER_ID $tofile" + fi + elif [[ "${to:0:4}" == "ftp:" ]] ; then + if [[ "$cert" != "challenge token" ]] ; then + error_exit "ftp is not a secure method for copying certificates or keys" + fi + debug "using ftp to copy the file from $from" + ftpuser=$(echo "$to"| awk -F: '{print $2}') + ftppass=$(echo "$to"| awk -F: '{print $3}') + ftphost=$(echo "$to"| awk -F: '{print $4}') + ftplocn=$(echo "$to"| awk -F: '{print $5}') + ftpdirn=$(dirname "$ftplocn") + ftpfile=$(basename "$ftplocn") + fromdir=$(dirname "$from") + fromfile=$(basename "$from") + debug "ftp user=$ftpuser - pass=$ftppass - host=$ftphost dir=$ftpdirn file=$ftpfile" + debug "from dir=$fromdir file=$fromfile" + ftp -n <<- _EOF + open $ftphost + user $ftpuser $ftppass + cd $ftpdirn + lcd $fromdir + put $fromfile + _EOF + elif [[ "${to:0:5}" == "sftp:" ]] ; then + debug "using sftp to copy the file from $from" + ftpuser=$(echo "$to"| awk -F: '{print $2}') + ftppass=$(echo "$to"| awk -F: '{print $3}') + ftphost=$(echo "$to"| awk -F: '{print $4}') + ftplocn=$(echo "$to"| awk -F: '{print $5}') + ftpdirn=$(dirname "$ftplocn") + ftpfile=$(basename "$ftplocn") + fromdir=$(dirname "$from") + fromfile=$(basename "$from") + debug "sftp user=$ftpuser - pass=$ftppass - host=$ftphost dir=$ftpdirn file=$ftpfile" + debug "from dir=$fromdir file=$fromfile" + sshpass -p "$ftppass" sftp "$ftpuser@$ftphost" <<- _EOF + cd $ftpdirn + lcd $fromdir + put $fromfile + _EOF + elif [[ "${to:0:5}" == "davs:" ]] ; then + debug "using davs to copy the file from $from" + davsuser=$(echo "$to"| awk -F: '{print $2}') + davspass=$(echo "$to"| awk -F: '{print $3}') + davshost=$(echo "$to"| awk -F: '{print $4}') + davsport=$(echo "$to"| awk -F: '{print $5}') + davslocn=$(echo "$to"| awk -F: '{print $6}') + davsdirn=$(dirname "$davslocn") + davsfile=$(basename "$davslocn") + fromdir=$(dirname "$from") + fromfile=$(basename "$from") + debug "davs user=$davsuser - pass=$davspass - host=$davshost port=$davsport dir=$davsdirn file=$davsfile" + debug "from dir=$fromdir file=$fromfile" + curl -u "${davsuser}:${davspass}" -T "${fromdir}/${fromfile}" "https://${davshost}:${davsport}${davsdirn}/${davsfile}" + else + if ! mkdir -p "$(dirname "$to")" ; then + error_exit "cannot create ACL directory $(basename "$to")" + fi + if [[ "$GETSSL_IGNORE_CP_PRESERVE" == "true" ]]; then + if ! cp "$from" "$to" ; then + error_exit "cannot copy $from to $to" + fi + else + if ! cp -p "$from" "$to" ; then + error_exit "cannot copy $from to $to" + fi + fi + if [[ "$cert" == "challenge token" ]] && [[ -n "$TOKEN_USER_ID" ]]; then + chown "$TOKEN_USER_ID" "$to" + fi + fi + debug "copied $from to $to" + done +} + +create_csr() { # create a csr using a given key (if it doesn't already exist) + csr_file=$1 + csr_key=$2 + # check if domain csr exists - if not then create it + if [[ -s "$csr_file" ]]; then + debug "domain csr exists at - $csr_file" + # check all domains in config are in csr + if [[ "$IGNORE_DIRECTORY_DOMAIN" == "true" ]]; then + alldomains=$(echo "$SANS" | sed -e 's/ //g; s/,$//; y/,/\n/' | sort -u) + else + alldomains=$(echo "$DOMAIN,$SANS" | sed -e 's/ //g; s/,$//; y/,/\n/' | sort -u) + fi + domains_in_csr=$(openssl req -text -noout -in "$csr_file" \ + | sed -n -e 's/^ *Subject: .* CN=\([A-Za-z0-9.-]*\).*$/\1/p; /^ *DNS:.../ { s/ *DNS://g; y/,/\n/; p; }' \ + | sort -u) + for d in $alldomains; do + if [[ "$(echo "${domains_in_csr}"| grep "^${d}$")" != "${d}" ]]; then + info "existing csr at $csr_file does not contain ${d} - re-create-csr"\ + ".... $(echo "${domains_in_csr}"| grep "^${d}$")" + _RECREATE_CSR=1 + fi + done + # check all domains in csr are in config + if [[ "$alldomains" != "$domains_in_csr" ]]; then + info "existing csr at $csr_file does not have the same domains as the config - re-create-csr" + _RECREATE_CSR=1 + fi + fi + # end of ... check if domain csr exists - if not then create it + + # if CSR does not exist, or flag set to recreate, then create csr + if [[ ! -s "$csr_file" ]] || [[ "$_RECREATE_CSR" == "1" ]]; then + info "creating domain csr - $csr_file" + # create a temporary config file, for portability. + tmp_conf=$(mktemp 2>/dev/null || mktemp -t getssl) + cat "$SSLCONF" > "$tmp_conf" + printf "[SAN]\n%s" "$SANLIST" >> "$tmp_conf" + # add OCSP Must-Staple to the domain csr + # if openssl version >= 1.1.0 one can also use "tlsfeature = status_request" + if [[ "$OCSP_MUST_STAPLE" == "true" ]]; then + printf "\n1.3.6.1.5.5.7.1.24 = DER:30:03:02:01:05" >> "$tmp_conf" + fi + openssl req -new -sha256 -key "$csr_key" -subj "$CSR_SUBJECT" -reqexts SAN -config "$tmp_conf" > "$csr_file" + rm -f "$tmp_conf" + fi +} + +create_key() { # create a domain key (if it doesn't already exist) + key_type=$1 # domain key type + key_loc=$2 # domain key location + key_len=$3 # domain key length - for rsa keys. + # check if key exists, if not then create it. + if [[ -s "$key_loc" ]]; then + debug "domain key exists at $key_loc - skipping generation" + # ideally need to check validity of domain key + else + umask 077 + info "creating key - $key_loc" + case "$key_type" in + rsa) + openssl genrsa "$key_len" > "$key_loc";; + prime256v1|secp384r1|secp521r1) + openssl ecparam -genkey -name "$key_type" > "$key_loc";; + *) + error_exit "unknown private key algorithm type $key_loc";; + esac + umask "$ORIG_UMASK" + # remove csr on generation of new domain key + if [[ -e "${key_loc%.*}.csr" ]]; then + rm -f "${key_loc%.*}.csr" + fi + fi +} + +create_order() { + dstring="[" + for d in $alldomains; do + dstring="${dstring}{\"type\":\"dns\",\"value\":\"$d\"}," + done + dstring="${dstring::${#dstring}-1}]" + # request NewOrder currently seems to ignore the dates .... + # dstring="${dstring},\"notBefore\": \"$(date -d "-1 hour" --utc +%FT%TZ)\"" + # dstring="${dstring},\"notAfter\": \"$(date -d "2 days" --utc +%FT%TZ)\"" + request="{\"identifiers\": $dstring}" + send_signed_request "$URL_newOrder" "$request" + OrderLink=$(echo "$responseHeaders" | grep -i location | awk '{print $2}'| tr -d '\r\n ') + debug "Order link $OrderLink" + FinalizeLink=$(json_get "$response" "finalize") + dn=0 + for d in $alldomains; do + # get authorizations link + AuthLink[$dn]=$(json_get "$response" "identifiers" "value" "$d" "authorizations" "x") + debug "authorizations link for $d - ${AuthLink[$dn]}" + ((dn++)) + done +} + +date_epoc() { # convert the date into epoch time + if [[ "$os" == "bsd" ]]; then + date -j -f "%b %d %T %Y %Z" "$1" +%s + elif [[ "$os" == "mac" ]]; then + date -j -f "%b %d %T %Y %Z" "$1" +%s + elif [[ "$os" == "busybox" ]]; then + de_ld=$(echo "$1" | awk '{print $1 $2 $3 $4}') + date -D "%b %d %T %Y" -d "$de_ld" +%s + else + date -d "$1" +%s + fi + +} + +date_fmt() { # format date from epoc time to YYYY-MM-DD + if [[ "$os" == "bsd" ]]; then #uses older style date function. + date -j -f "%s" "$1" +%F + elif [[ "$os" == "mac" ]]; then # macOS uses older BSD style date. + date -j -f "%s" "$1" +%F + else + date -d "@$1" +%F + fi +} + +date_renew() { # calculates the renewal time in epoch + date_now_s=$( date +%s ) + echo "$((date_now_s + RENEW_ALLOW*24*60*60))" +} + +debug() { # write out debug info if the debug flag has been set + if [[ ${_USE_DEBUG} -eq 1 ]]; then + echo " " + echo "$@" + fi +} + +error_exit() { # give error message on error exit + echo -e "${PROGNAME}: ${1:-"Unknown Error"}" >&2 + clean_up + exit 1 +} + +fulfill_challenges() { +dn=0 +for d in $alldomains; do + # $d is domain in current loop, which is number $dn for ACL + info "Verifying $d" + if [[ "$USE_SINGLE_ACL" == "true" ]]; then + DOMAIN_ACL="${ACL[0]}" + else + DOMAIN_ACL="${ACL[$dn]}" + fi + + # request a challenge token from ACME server + debug "Requesting challenge tokens" + if [[ $API -eq 1 ]]; then + request="{\"resource\":\"new-authz\",\"identifier\":{\"type\":\"dns\",\"value\":\"$d\"}}" + send_signed_request "$URL_new_authz" "$request" + debug "completed send_signed_request" + + # check if we got a valid response and token, if not then error exit + if [[ -n "$code" ]] && [[ ! "$code" == '201' ]] ; then + error_exit "new-authz error: $response" + fi + else + send_signed_request "${AuthLink[$dn]}" "" + fi + + if [[ $response_status == "valid" ]]; then + info "$d is already validated" + if [[ "$DEACTIVATE_AUTH" == "true" ]]; then + deactivate_url="$(echo "$responseHeaders" | awk ' $1 ~ "^Location" {print $2}' | tr -d "\r")" + deactivate_url_list+=" $deactivate_url " + debug "url added to deactivate list ${deactivate_url}" + debug "deactivate list is now $deactivate_url_list" + fi + # increment domain-counter + ((dn++)) + else + PREVIOUSLY_VALIDATED="false" + if [[ $VALIDATE_VIA_DNS == "true" ]]; then # set up the correct DNS token for verification + if [[ $API -eq 1 ]]; then + # get the dns component of the ACME response + # get the token from the dns component + token=$(json_get "$response" "token" "dns-01") + # get the uri from the dns component + uri=$(json_get "$response" "uri" "dns-01") + debug uri "$uri" + else # APIv2 + debug "authlink response = $response" + # get the token from the http-01 component + token=$(json_get "$response" "challenges" "type" "dns-01" "token") + # get the uri from the http component + uri=$(json_get "$response" "challenges" "type" "dns-01" "url") + debug uri "$uri" + fi + + keyauthorization="$token.$thumbprint" + debug keyauthorization "$keyauthorization" + + #create signed authorization key from token. + auth_key=$(printf '%s' "$keyauthorization" | openssl dgst -sha256 -binary \ + | openssl base64 -e \ + | tr -d '\n\r' \ + | sed -e 's:=*$::g' -e 'y:+/:-_:') + debug auth_key "$auth_key" + + debug "adding dns via command: $DNS_ADD_COMMAND $d $auth_key" + if ! eval "$DNS_ADD_COMMAND" "$d" "$auth_key" ; then + error_exit "DNS_ADD_COMMAND failed for domain $d" + fi + + # find a primary / authoritative DNS server for the domain + if [[ -z "$AUTH_DNS_SERVER" ]]; then + get_auth_dns "$d" + else + primary_ns="$AUTH_DNS_SERVER" + fi + debug primary_ns "$primary_ns" + + # make a directory to hold pending dns-challenges + if [[ ! -d "$TEMP_DIR/dns_verify" ]]; then + mkdir "$TEMP_DIR/dns_verify" + fi + + # generate a file with the current variables for the dns-challenge + cat > "$TEMP_DIR/dns_verify/$d" <<- _EOF_ + token="${token}" + uri="${uri}" + keyauthorization="${keyauthorization}" + d="${d}" + primary_ns="${primary_ns}" + auth_key="${auth_key}" + _EOF_ + + else # set up the correct http token for verification + if [[ $API -eq 1 ]]; then + # get the token from the http component + token=$(json_get "$response" "token" "http-01") + # get the uri from the http component + uri=$(json_get "$response" "uri" "http-01") + debug uri "$uri" + else # APIv2 + send_signed_request "${AuthLink[$dn]}" "" + debug "authlink response = $response" + # get the token from the http-01 component + token=$(json_get "$response" "challenges" "type" "http-01" "token") + # get the uri from the http component + uri=$(json_get "$response" "challenges" "type" "http-01" "url" | head -n1) + debug uri "$uri" + fi + + #create signed authorization key from token. + keyauthorization="$token.$thumbprint" + + # save variable into temporary file + echo -n "$keyauthorization" > "$TEMP_DIR/$token" + chmod 644 "$TEMP_DIR/$token" + + # copy to token to acme challenge location + umask 0022 + IFS=\; read -r -a token_locations <<<"$DOMAIN_ACL" + for t_loc in "${token_locations[@]}"; do + debug "copying file from $TEMP_DIR/$token to ${t_loc}" + copy_file_to_location "challenge token" \ + "$TEMP_DIR/$token" \ + "${t_loc}/$token" + done + umask "$ORIG_UMASK" + + wellknown_url="${CHALLENGE_CHECK_TYPE}://${d}/.well-known/acme-challenge/$token" + debug wellknown_url "$wellknown_url" + + if [[ "$SKIP_HTTP_TOKEN_CHECK" == "true" ]]; then + info "SKIP_HTTP_TOKEN_CHECK=true so not checking that token is working correctly" + else + sleep "$HTTP_TOKEN_CHECK_WAIT" + # check that we can reach the challenge ourselves, if not, then error + if [[ ! "$(curl --user-agent "$CURL_USERAGENT" -k --silent --location "$wellknown_url")" == "$keyauthorization" ]]; then + error_exit "for some reason could not reach $wellknown_url - please check it manually" + fi + fi + + check_challenge_completion "$uri" "$d" "$keyauthorization" + + debug "remove token from ${DOMAIN_ACL}" + IFS=\; read -r -a token_locations <<<"$DOMAIN_ACL" + for t_loc in "${token_locations[@]}"; do + if [[ "${t_loc:0:4}" == "ssh:" ]] ; then + sshhost=$(echo "${t_loc}"| awk -F: '{print $2}') + command="rm -f ${t_loc:(( ${#sshhost} + 5))}/${token:?}" + debug "running following command to remove token" + debug "ssh $SSH_OPTS $sshhost ${command}" + # shellcheck disable=SC2029 + # shellcheck disable=SC2086 + ssh $SSH_OPTS "$sshhost" "${command}" 1>/dev/null 2>&1 + rm -f "${TEMP_DIR:?}/${token:?}" + elif [[ "${t_loc:0:4}" == "ftp:" ]] ; then + debug "using ftp to remove token file" + ftpuser=$(echo "${t_loc}"| awk -F: '{print $2}') + ftppass=$(echo "${t_loc}"| awk -F: '{print $3}') + ftphost=$(echo "${t_loc}"| awk -F: '{print $4}') + ftplocn=$(echo "${t_loc}"| awk -F: '{print $5}') + debug "ftp user=$ftpuser - pass=$ftppass - host=$ftphost location=$ftplocn" + ftp -n <<- EOF + open $ftphost + user $ftpuser $ftppass + cd $ftplocn + delete ${token:?} + EOF + else + rm -f "${t_loc:?}/${token:?}" + fi + done + fi + # increment domain-counter + ((dn++)) + fi +done # end of ... loop through domains for cert ( from SANS list) +# perform validation if via DNS challenge +if [[ $VALIDATE_VIA_DNS == "true" ]]; then + # loop through dns-variable files to check if dns has been changed + for dnsfile in "$TEMP_DIR"/dns_verify/*; do + if [[ -e "$dnsfile" ]]; then + debug "loading DNSfile: $dnsfile" + # shellcheck source=/dev/null + . "$dnsfile" + + # check for token at public dns server, waiting for a valid response. + for ns in $primary_ns; do + debug "checking dns at $ns" + ntries=0 + check_dns="fail" + while [[ "$check_dns" == "fail" ]]; do + if [[ "$os" == "cygwin" ]]; then + check_result=$(nslookup -type=txt "_acme-challenge.${d}" "${ns}" \ + | grep ^_acme -A2\ + | grep '"'|awk -F'"' '{ print $2}') + elif [[ "$DNS_CHECK_FUNC" == "drill" ]] || [[ "$DNS_CHECK_FUNC" == "dig" ]]; then + check_result=$($DNS_CHECK_FUNC TXT "_acme-challenge.${d}" "@${ns}" \ + | grep '300 IN TXT'|awk -F'"' '{ print $2}') + elif [[ "$DNS_CHECK_FUNC" == "host" ]]; then + check_result=$($DNS_CHECK_FUNC -t TXT "_acme-challenge.${d}" "${ns}" \ + | grep 'descriptive text'|awk -F'"' '{ print $2}') + else + check_result=$(nslookup -type=txt "_acme-challenge.${d}" "${ns}" \ + | grep 'text ='|awk -F'"' '{ print $2}') + fi + debug "expecting $auth_key" + debug "${ns} gave ... $check_result" + + if [[ "$check_result" == *"$auth_key"* ]]; then + check_dns="success" + else + if [[ $ntries -lt 100 ]]; then + ntries=$(( ntries + 1 )) + info "checking DNS at ${ns} for ${d}. Attempt $ntries/100 gave wrong result, "\ + "waiting $DNS_WAIT secs before checking again" + sleep $DNS_WAIT + else + debug "dns check failed - removing existing value" + error_exit "checking _acme-challenge.${d} gave $check_result not $auth_key" + fi + fi + done + done + fi + done + + if [[ "$DNS_EXTRA_WAIT" -gt 0 && "$PREVIOUSLY_VALIDATED" != "true" ]]; then + info "sleeping $DNS_EXTRA_WAIT seconds before asking the ACME-server to check the dns" + sleep "$DNS_EXTRA_WAIT" + fi + + # loop through dns-variable files to let the ACME server check the challenges + for dnsfile in "$TEMP_DIR"/dns_verify/*; do + if [[ -e "$dnsfile" ]]; then + debug "loading DNSfile: $dnsfile" + # shellcheck source=/dev/null + . "$dnsfile" + + check_challenge_completion "$uri" "$d" "$keyauthorization" + + debug "remove DNS entry" + eval "$DNS_DEL_COMMAND" "$d" "$auth_key" + # remove $dnsfile after each loop. + rm -f "$dnsfile" + fi + done +fi +# end of ... perform validation if via DNS challenge +#end of varify each domain. +} + +get_auth_dns() { # get the authoritative dns server for a domain (sets primary_ns ) + gad_d="$1" # domain name + gad_s="$PUBLIC_DNS_SERVER" # start with PUBLIC_DNS_SERVER + + if [[ "$os" == "cygwin" ]]; then + all_auth_dns_servers=$(nslookup -type=soa "${d}" ${PUBLIC_DNS_SERVER} 2>/dev/null \ + | grep "primary name server" \ + | awk '{print $NF}') + if [[ -z "$all_auth_dns_servers" ]]; then + error_exit "couldn't find primary DNS server - please set AUTH_DNS_SERVER in config" + fi + primary_ns="$all_auth_dns_servers" + return + fi + + if [[ "$DNS_CHECK_FUNC" == "drill" ]] || [[ "$DNS_CHECK_FUNC" == "dig" ]]; then + if [[ -z "$gad_s" ]]; then #checking for CNAMEs + res=$($DNS_CHECK_FUNC CNAME "$gad_d"| grep "^$gad_d") + else + res=$($DNS_CHECK_FUNC CNAME "$gad_d" "@$gad_s"| grep "^$gad_d") + fi + if [[ -n "$res" ]]; then # domain is a CNAME so get main domain + gad_d=$(echo "$res"| awk '{print $5}' |sed 's/\.$//g') + fi + if [[ -z "$gad_s" ]]; then #checking for CNAMEs + res=$($DNS_CHECK_FUNC NS "$gad_d"| grep "^$gad_d") + else + res=$($DNS_CHECK_FUNC NS "$gad_d" "@$gad_s"| grep "^$gad_d") + fi + if [[ -z "$res" ]]; then + error_exit "couldn't find primary DNS server - please set AUTH_DNS_SERVER in config" + else + all_auth_dns_servers=$(echo "$res" | awk '$4 ~ "NS" {print $5}' | sed 's/\.$//g'|tr '\n' ' ') + fi + if [[ $CHECK_ALL_AUTH_DNS == "true" ]]; then + primary_ns="$all_auth_dns_servers" + else + primary_ns=$(echo "$all_auth_dns_servers" | awk '{print $1}') + fi + return + fi + + if [[ "$DNS_CHECK_FUNC" == "host" ]]; then + if [[ -z "$gad_s" ]]; then + res=$($DNS_CHECK_FUNC -t NS "$gad_d"| grep "name server") + else + res=$($DNS_CHECK_FUNC -t NS "$gad_d" "$gad_s"| grep "name server") + fi + if [[ -z "$res" ]]; then + error_exit "couldn't find primary DNS server - please set AUTH_DNS_SERVER in config" + else + all_auth_dns_servers=$(echo "$res" | awk '{print $4}' | sed 's/\.$//g'|tr '\n' ' ') + fi + if [[ $CHECK_ALL_AUTH_DNS == "true" ]]; then + primary_ns="$all_auth_dns_servers" + else + primary_ns=$(echo "$all_auth_dns_servers" | awk '{print $1}') + fi + return + fi + + res=$(nslookup -debug=1 -type=soa -type=ns "$gad_d" ${gad_s}) + + if [[ "$(echo "$res" | grep -c "Non-authoritative")" -gt 0 ]]; then + # this is a Non-authoritative server, need to check for an authoritative one. + gad_s=$(echo "$res" | awk '$2 ~ "nameserver" {print $4; exit }' |sed 's/\.$//g') + if [[ "$(echo "$res" | grep -c "an't find")" -gt 0 ]]; then + # if domain name doesn't exist, then find auth servers for next level up + gad_s=$(echo "$res" | awk '$1 ~ "origin" {print $3; exit }') + gad_d=$(echo "$res" | awk '$1 ~ "->" {print $2; exit}') + fi + fi + + if [[ -z "$gad_s" ]]; then + res=$(nslookup -debug=1 -type=soa -type=ns "$gad_d") + else + res=$(nslookup -debug=1 -type=soa -type=ns "$gad_d" "${gad_s}") + fi + + if [[ "$(echo "$res" | grep -c "canonical name")" -gt 0 ]]; then + gad_d=$(echo "$res" | awk ' $2 ~ "canonical" {print $5; exit }' |sed 's/\.$//g') + elif [[ "$(echo "$res" | grep -c "an't find")" -gt 0 ]]; then + gad_s=$(echo "$res" | awk ' $1 ~ "origin" {print $3; exit }') + gad_d=$(echo "$res"| awk '$1 ~ "->" {print $2; exit}') + fi + + all_auth_dns_servers=$(nslookup -type=soa -type=ns "$gad_d" "$gad_s" \ + | awk ' $2 ~ "nameserver" {print $4}' \ + | sed 's/\.$//g'| tr '\n' ' ') + if [[ $CHECK_ALL_AUTH_DNS == "true" ]]; then + primary_ns="$all_auth_dns_servers" + else + primary_ns=$(echo "$all_auth_dns_servers" | awk '{print $1}') + fi +} + +get_certificate() { # get certificate for csr, if all domains validated. + gc_csr=$1 # the csr file + gc_certfile=$2 # The filename for the certificate + gc_cafile=$3 # The filename for the CA certificate + + der=$(openssl req -in "$gc_csr" -outform DER | urlbase64) + if [[ $API -eq 1 ]]; then + send_signed_request "$URL_new_cert" "{\"resource\": \"new-cert\", \"csr\": \"$der\"}" "needbase64" + # convert certificate information into correct format and save to file. + CertData=$(awk ' $1 ~ "^Location" {print $2}' "$CURL_HEADER" |tr -d '\r') + if [[ "$CertData" ]] ; then + echo -----BEGIN CERTIFICATE----- > "$gc_certfile" + curl --user-agent "$CURL_USERAGENT" --silent "$CertData" | openssl base64 -e >> "$gc_certfile" + echo -----END CERTIFICATE----- >> "$gc_certfile" + info "Certificate saved in $CERT_FILE" + fi + + # If certificate wasn't a valid certificate, error exit. + if [[ -z "$CertData" ]] ; then + response2=$(echo "$response" | fold -w64 |openssl base64 -d) + debug "response was $response" + error_exit "Sign failed: $(echo "$response2" | grep "detail")" + fi + + # get a copy of the CA certificate. + IssuerData=$(grep -i '^Link' "$CURL_HEADER" \ + | cut -d " " -f 2\ + | cut -d ';' -f 1 \ + | sed 's///g') + if [[ "$IssuerData" ]] ; then + echo -----BEGIN CERTIFICATE----- > "$gc_cafile" + curl --user-agent "$CURL_USERAGENT" --silent "$IssuerData" | openssl base64 -e >> "$gc_cafile" + echo -----END CERTIFICATE----- >> "$gc_cafile" + info "The intermediate CA cert is in $gc_cafile" + fi + else # APIv2 + info "Requesting Finalize Link" + send_signed_request "$FinalizeLink" "{\"csr\": \"$der\"}" "needbase64" + info Requesting Order Link + debug "order link was $OrderLink" + send_signed_request "$OrderLink" "" + # if ACME response is processing (still creating certificates) then wait and try again. + while [[ "$response_status" == "processing" ]]; do + info "ACME server still Processing certificates" + sleep 5 + send_signed_request "$OrderLink" "" + done + info "Requesting certificate" + CertData=$(json_get "$response" "certificate") + send_signed_request "$CertData" "" "" "$FULL_CHAIN" + info "Full certificate saved in $FULL_CHAIN" + awk -v CERT_FILE="$gc_certfile" -v CA_CERT="$gc_cafile" 'BEGIN {outfile=CERT_FILE} split_after==1 {outfile=CA_CERT;split_after=0} /-----END CERTIFICATE-----/ {split_after=1} {print > outfile}' "$FULL_CHAIN" + info "Certificate saved in $gc_certfile" + fi +} + +get_cr() { # get curl response + url="$1" + debug url "$url" + response=$(curl --user-agent "$CURL_USERAGENT" --silent "$url") + ret=$? + debug response "$response" + code=$(json_get "$response" status) + debug code "$code" + debug "get_cr return code $ret" + return $ret +} + +get_os() { # function to get the current Operating System + uname_res=$(uname -s) + if [[ $(date -h 2>&1 | grep -ic busybox) -gt 0 ]]; then + os="busybox" + elif [[ ${uname_res} == "Linux" ]]; then + os="linux" + elif [[ ${uname_res} == "FreeBSD" ]]; then + os="bsd" + elif [[ ${uname_res} == "Darwin" ]]; then + os="mac" + elif [[ ${uname_res:0:6} == "CYGWIN" ]]; then + os="cygwin" + elif [[ ${uname_res:0:5} == "MINGW" ]]; then + os="mingw" + else + os="unknown" + fi + debug "detected os type = $os" + if [[ -f /etc/issue ]]; then + debug "Running $(cat /etc/issue)" + fi +} + +get_signing_params() { # get signing parameters from key + skey=$1 + if openssl rsa -in "${skey}" -noout 2>/dev/null ; then # RSA key + pub_exp64=$(openssl rsa -in "${skey}" -noout -text \ + | grep publicExponent \ + | grep -oE "0x[a-f0-9]+" \ + | cut -d'x' -f2 \ + | hex2bin \ + | urlbase64) + pub_mod64=$(openssl rsa -in "${skey}" -noout -modulus \ + | cut -d'=' -f2 \ + | hex2bin \ + | urlbase64) + + jwk='{"e":"'"${pub_exp64}"'","kty":"RSA","n":"'"${pub_mod64}"'"}' + jwkalg="RS256" + signalg="sha256" + elif openssl ec -in "${skey}" -noout 2>/dev/null ; then # Elliptic curve key. + crv="$(openssl ec -in "$skey" -noout -text 2>/dev/null | awk '$2 ~ "CURVE:" {print $3}')" + if [[ -z "$crv" ]]; then + gsp_keytype="$(openssl ec -in "$skey" -noout -text 2>/dev/null \ + | grep "^ASN1 OID:" \ + | awk '{print $3}')" + case "$gsp_keytype" in + prime256v1) crv="P-256" ;; + secp384r1) crv="P-384" ;; + secp521r1) crv="P-521" ;; + *) error_exit "invalid curve algorithm type $gsp_keytype";; + esac + fi + case "$crv" in + P-256) jwkalg="ES256" ; signalg="sha256" ;; + P-384) jwkalg="ES384" ; signalg="sha384" ;; + P-521) jwkalg="ES512" ; signalg="sha512" ;; + *) error_exit "invalid curve algorithm type $crv";; + esac + pubtext="$(openssl ec -in "$skey" -noout -text 2>/dev/null \ + | awk '/^pub:/{p=1;next}/^ASN1 OID:/{p=0}p' \ + | tr -d ": \n\r")" + mid=$(( (${#pubtext} -2) / 2 + 2 )) + x64=$(echo "$pubtext" | cut -b 3-$mid | hex2bin | urlbase64) + y64=$(echo "$pubtext" | cut -b $((mid+1))-${#pubtext} | hex2bin | urlbase64) + jwk='{"crv":"'"$crv"'","kty":"EC","x":"'"$x64"'","y":"'"$y64"'"}' + else + error_exit "Invalid key file" + fi + thumbprint="$(printf "%s" "$jwk" | openssl dgst -sha256 -binary | urlbase64)" + debug "jwk alg = $jwkalg" +} + +graceful_exit() { # normal exit function. + clean_up + exit +} + +help_message() { # print out the help message + cat <<- _EOF_ + $PROGNAME ver. $VERSION + Obtain SSL certificates from the letsencrypt.org ACME server + + $(usage) + + Options: + -a, --all Check all certificates + -d, --debug Output debug information + -c, --create Create default config files + -f, --force Force renewal of cert (overrides expiry checks) + -h, --help Display this help message and exit + -q, --quiet Quiet mode (only outputs on error, success of new cert, or getssl was upgraded) + -Q, --mute Like -q, but also mute notification about successful upgrade + -r, --revoke "cert" "key" [CA_server] Revoke a certificate (the cert and key are required) + -u, --upgrade Upgrade getssl if a more recent version is available + -k, --keep "#" Maximum number of old getssl versions to keep when upgrading + -U, --nocheck Do not check if a more recent version is available + -w working_dir "Working directory" + + _EOF_ +} + +hex2bin() { # Remove spaces, add leading zero, escape as hex string ensuring no trailing new line char +# printf -- "$(cat | os_esed -e 's/[[:space:]]//g' -e 's/^(.(.{2})*)$/0\1/' -e 's/(.{2})/\\x\1/g')" + echo -e -n "$(cat | os_esed -e 's/[[:space:]]//g' -e 's/^(.(.{2})*)$/0\1/' -e 's/(.{2})/\\x\1/g')" +} + +info() { # write out info as long as the quiet flag has not been set. + if [[ ${_QUIET} -eq 0 ]]; then + echo "$@" + fi +} + +json_awk() { # AWK json converter used for API2 - needs tidying up ;) +# shellcheck disable=SC2086 +echo $1 | awk ' +{ + tokenize($0) # while(get_token()) {print TOKEN} + if (0 == parse()) { + apply(JPATHS, NJPATHS) + } +} + +function apply (ary,size,i) { + for (i=1; i NTOKENS) to = NTOKENS + for (i = from; i < ITOKENS; i++) + context = context sprintf("%s ", TOKENS[i]) + context = context "<<" got ">> " + for (i = ITOKENS + 1; i <= to; i++) + context = context sprintf("%s ", TOKENS[i]) + scream("json_awk expected <" expected "> but got <" got "> at input token " ITOKENS "\n" context) +} + +function reset() { + TOKEN=""; delete TOKENS; NTOKENS=ITOKENS=0 + delete JPATHS; NJPATHS=0 + VALUE="" +} + +function scream(msg) { + FAILS[FILENAME] = FAILS[FILENAME] (FAILS[FILENAME]!="" ? "\n" : "") msg + msg = FILENAME ": " msg + print msg >"/dev/stderr" +} + +function tokenize(a1,pq,pb,ESCAPE,CHAR,STRING,NUMBER,KEYWORD,SPACE) { + SPACE="[[:space:]]+" + gsub(/\"[^[:cntrl:]\"\\]*((\\[^u[:cntrl:]]|\\u[0-9a-fA-F]{4})[^[:cntrl:]\"\\]*)*\"|-?(0|[1-9][0-9]*)([.][0-9]*)?([eE][+-]?[0-9]*)?|null|false|true|[[:space:]]+|./, "\n&", a1) + gsub("\n" SPACE, "\n", a1) + sub(/^\n/, "", a1) + ITOKENS=0 # get_token() helper + return NTOKENS = split(a1, TOKENS, /\n/) +}' +} + +json_get() { # get values from json + if [[ -z "$1" ]] || [[ "$1" == "null" ]]; then + echo "json was blank" + return + fi + if [[ $API = 1 ]]; then + # remove newlines, so it's a single chunk of JSON + json_data=$( echo "$1" | tr '\n' ' ') + # if $3 is defined, this is the section which the item is in. + if [[ -n "$3" ]]; then + jg_section=$(echo "$json_data" | awk -F"[}]" '{for(i=1;i<=NF;i++){if($i~/\"'"${3}"'\"/){print $i}}}') + if [[ "$2" == "uri" ]]; then + jg_subsect=$(echo "$jg_section" | awk -F"[,]" '{for(i=1;i<=NF;i++){if($i~/\"'"${2}"'\"/){print $(i)}}}') + jg_result=$(echo "$jg_subsect" | awk -F'"' '{print $4}') + else + jg_result=$(echo "$jg_section" | awk -F"[,:}]" '{for(i=1;i<=NF;i++){if($i~/\"'"${2}"'\"/){print $(i+1)}}}') + fi + else + jg_result=$(echo "$json_data" |awk -F"[,:}]" '{for(i=1;i<=NF;i++){if($i~/\"'"${2}"'\"/){print $(i+1)}}}') + fi + # check number of quotes + jg_q=${jg_result//[^\"]/} + # if 2 quotes, assume it's a quoted variable and just return the data within the quotes. + if [[ ${#jg_q} -eq 2 ]]; then + echo "$jg_result" | awk -F'"' '{print $2}' + else + echo "$jg_result" + fi + else + if [[ -n "$6" ]]; then + full=$(json_awk "$1") + section=$(echo "$full" | grep "\"$2\"" | grep "\"$3\"" | grep "\"$4\"" | awk -F"," '{print $2}') + echo "$full" | grep "^..${5}\",$section\]" | awk '{print $2}' | tr -d '"' + elif [[ -n "$5" ]]; then + full=$(json_awk "$1") + section=$(echo "$full" | grep "\"$2\"" | grep "\"$3\"" | grep "\"$4\"" | awk -F"," '{print $2}') + echo "$full" | grep "^..${2}\",$section" | grep "$5" | awk '{print $2}' | tr -d '"' + elif [[ -n "$3" ]]; then + json_awk "$1" | grep "^..${2}...${3}" | awk '{print $2}' | tr -d '"' + elif [[ -n "$2" ]]; then + json_awk "$1" | grep "^..${2}" | awk '{print $2}' | tr -d '"' + else + json_awk "$1" + fi + fi +} + +os_esed() { # Use different sed version for different os types (extended regex) + if [[ "$os" == "bsd" ]]; then # BSD requires -E flag for extended regex + sed -E "${@}" + elif [[ "$os" == "mac" ]]; then # MAC uses older BSD style sed. + sed -E "${@}" + else + sed -r "${@}" + fi +} + +purge_archive() { # purge archive of old, invalid, certificates + arcdir="$1/archive" + debug "purging archives in ${arcdir}/" + for padir in "$arcdir"/????_??_??_??_??; do + # check each directory + if [[ -d "$padir" ]]; then + tstamp=$(basename "$padir"| awk -F"_" '{print $1"-"$2"-"$3" "$4":"$5}') + if [[ "$os" == "bsd" ]]; then + direpoc=$(date -j -f "%F %H:%M" "$tstamp" +%s) + elif [[ "$os" == "mac" ]]; then + direpoc=$(date -j -f "%F %H:%M" "$tstamp" +%s) + else + direpoc=$(date -d "$tstamp" +%s) + fi + current_epoc=$(date "+%s") + # as certs currently valid for 90 days, purge anything older than 100 + purgedate=$((current_epoc - 60*60*24*100)) + if [[ "$direpoc" -lt "$purgedate" ]]; then + echo "purge $padir" + rm -rf "${padir:?}" + fi + fi + done +} + +reload_service() { # Runs a command to reload services ( via ssh if needed) + if [[ -n "$RELOAD_CMD" ]]; then + info "reloading SSL services" + if [[ "${RELOAD_CMD:0:4}" == "ssh:" ]] ; then + sshhost=$(echo "$RELOAD_CMD"| awk -F: '{print $2}') + command=${RELOAD_CMD:(( ${#sshhost} + 5))} + debug "running following command to reload cert" + debug "ssh $SSH_OPTS $sshhost ${command}" + # shellcheck disable=SC2029 + # shellcheck disable=SC2086 + ssh $SSH_OPTS "$sshhost" "${command}" 1>/dev/null 2>&1 + # allow 2 seconds for services to restart + sleep 2 + else + debug "running reload command $RELOAD_CMD" + if ! eval "$RELOAD_CMD" ; then + error_exit "error running $RELOAD_CMD" + fi + fi + fi +} + +revoke_certificate() { # revoke a certificate + debug "revoking cert $REVOKE_CERT" + debug "using key $REVOKE_KEY" + ACCOUNT_KEY="$REVOKE_KEY" + # need to set the revoke key as "account_key" since it's used in send_signed_request. + get_signing_params "$REVOKE_KEY" + TEMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t getssl) + debug "revoking from $CA" + rcertdata=$(openssl x509 -in "$REVOKE_CERT" -inform PEM -outform DER | urlbase64) + send_signed_request "$URL_revoke" "{\"resource\": \"revoke-cert\", \"certificate\": \"$rcertdata\"}" + if [[ $code -eq "200" ]]; then + info "certificate revoked" + else + error_exit "Revocation failed: $(echo "$response" | grep "detail")" + fi +} + +requires() { # check if required function is available + if [[ "$#" -gt 1 ]]; then # if more than 1 value, check list + for i in "$@"; do + if [[ "$i" == "${!#}" ]]; then # if on last variable then exit as not found + error_exit "this script requires one of: ${*:1:$(($#-1))}" + fi + res=$(command -v "$i" 2>/dev/null) + debug "checking for $i ... $res" + if [[ -n "$res" ]]; then # if function found, then set variable to function and return + debug "function $i found at $res - setting ${!#} to $i" + eval "${!#}=\$i" + return + fi + done + else # only one value, so check it. + result=$(command -v "$1" 2>/dev/null) + debug "checking for required $1 ... $result" + if [[ -z "$result" ]]; then + error_exit "This script requires $1 installed" + fi + fi +} + +set_server_type() { # uses SERVER_TYPE to set REMOTE_PORT and REMOTE_EXTRA + if [[ ${SERVER_TYPE} == "https" ]] || [[ ${SERVER_TYPE} == "webserver" ]]; then + REMOTE_PORT=443 + elif [[ ${SERVER_TYPE} == "ftp" ]]; then + REMOTE_PORT=21 + REMOTE_EXTRA="-starttls ftp" + elif [[ ${SERVER_TYPE} == "ftpi" ]]; then + REMOTE_PORT=990 + elif [[ ${SERVER_TYPE} == "imap" ]]; then + REMOTE_PORT=143 + REMOTE_EXTRA="-starttls imap" + elif [[ ${SERVER_TYPE} == "imaps" ]]; then + REMOTE_PORT=993 + elif [[ ${SERVER_TYPE} == "pop3" ]]; then + REMOTE_PORT=110 + REMOTE_EXTRA="-starttls pop3" + elif [[ ${SERVER_TYPE} == "pop3s" ]]; then + REMOTE_PORT=995 + elif [[ ${SERVER_TYPE} == "smtp" ]]; then + REMOTE_PORT=25 + REMOTE_EXTRA="-starttls smtp" + elif [[ ${SERVER_TYPE} == "smtps_deprecated" ]]; then + REMOTE_PORT=465 + elif [[ ${SERVER_TYPE} == "smtps" ]] || [[ ${SERVER_TYPE} == "smtp_submission" ]]; then + REMOTE_PORT=587 + REMOTE_EXTRA="-starttls smtp" + elif [[ ${SERVER_TYPE} == "xmpp" ]]; then + REMOTE_PORT=5222 + REMOTE_EXTRA="-starttls xmpp" + elif [[ ${SERVER_TYPE} == "xmpps" ]]; then + REMOTE_PORT=5269 + elif [[ ${SERVER_TYPE} == "ldaps" ]]; then + REMOTE_PORT=636 + elif [[ ${SERVER_TYPE} =~ ^[0-9]+$ ]]; then + REMOTE_PORT=${SERVER_TYPE} + else + info "${DOMAIN}: unknown server type \"$SERVER_TYPE\" in SERVER_TYPE" + config_errors=true + fi +} + +send_signed_request() { # Sends a request to the ACME server, signed with your private key. + url=$1 + payload=$2 + needbase64=$3 + outfile=$4 # save response into this file (certificate data) + + debug url "$url" + + CURL_HEADER="$TEMP_DIR/curl.header" + dp="$TEMP_DIR/curl.dump" + + CURL="curl " + # shellcheck disable=SC2072 + if [[ "$($CURL -V | head -1 | cut -d' ' -f2 )" > "7.33" ]]; then + CURL="$CURL --http1.1 " + fi + + CURL="$CURL --user-agent $CURL_USERAGENT --silent --dump-header $CURL_HEADER " + + if [[ ${_USE_DEBUG} -eq 1 ]]; then + CURL="$CURL --trace-ascii $dp " + fi + + # convert payload to url base 64 + payload64="$(printf '%s' "${payload}" | urlbase64)" + + # get nonce from ACME server + if [[ $API -eq 1 ]]; then + nonceurl="$CA/directory" + nonce=$($CURL -I $nonceurl | grep "^Replay-Nonce:" | awk '{print $2}' | tr -d '\r\n ') + else # APIv2 + nonce=$($CURL -I "$URL_newNonce" | grep "^Replay-Nonce:" | awk '{print $2}' | tr -d '\r\n ') + fi + + nonceproblem="true" + while [[ "$nonceproblem" == "true" ]]; do + + # Build header with just our public key and algorithm information + header='{"alg": "'"$jwkalg"'", "jwk": '"$jwk"'}' + + # Build another header which also contains the previously received nonce and encode it as urlbase64 + if [[ $API -eq 1 ]]; then + protected='{"alg": "'"$jwkalg"'", "jwk": '"$jwk"', "nonce": "'"${nonce}"'", "url": "'"${url}"'"}' + protected64="$(printf '%s' "${protected}" | urlbase64)" + else # APIv2 + if [[ -z "$KID" ]]; then + debug "KID is blank, so using jwk" + protected='{"alg": "'"$jwkalg"'", "jwk": '"$jwk"', "nonce": "'"${nonce}"'", "url": "'"${url}"'"}' + protected64="$(printf '%s' "${protected}" | urlbase64)" + else + debug "using KID=${KID}" + protected="{\"alg\": \"$jwkalg\", \"kid\": \"$KID\",\"nonce\": \"${nonce}\", \"url\": \"${url}\"}" + protected64="$(printf '%s' "${protected}" | urlbase64)" + fi + fi + + # Sign header with nonce and our payload with our private key and encode signature as urlbase64 + sign_string "$(printf '%s' "${protected64}.${payload64}")" "${ACCOUNT_KEY}" "$signalg" + + # Send header + extended header + payload + signature to the acme-server + debug "payload = $payload" + if [[ $API -eq 1 ]]; then + body="{\"header\": ${header}," + body="${body}\"protected\": \"${protected64}\"," + body="${body}\"payload\": \"${payload64}\"," + body="${body}\"signature\": \"${signed64}\"}" + else + body="{" + body="${body}\"protected\": \"${protected64}\"," + body="${body}\"payload\": \"${payload64}\"," + body="${body}\"signature\": \"${signed64}\"}" + fi + + code="500" + loop_limit=5 + while [[ "$code" -eq 500 ]]; do + if [[ "$outfile" ]] ; then + $CURL -X POST -H "Content-Type: application/jose+json" --data "$body" "$url" > "$outfile" + response=$(cat "$outfile") + elif [[ "$needbase64" ]] ; then + response=$($CURL -X POST -H "Content-Type: application/jose+json" --data "$body" "$url" | urlbase64) + else + response=$($CURL -X POST -H "Content-Type: application/jose+json" --data "$body" "$url") + fi + + if [[ "$response" == "" ]]; then + error_exit "ERROR curl \"$url\" returned nothing" + fi + + responseHeaders=$(cat "$CURL_HEADER") + if [[ "$needbase64" && ${response##*()} != "{"* ]]; then + # response is in base64 too, decode + response=$(urlbase64_decode "$response") + fi + + debug responseHeaders "$responseHeaders" + debug response "$response" + code=$(awk ' $1 ~ "^HTTP" {print $2}' "$CURL_HEADER" | tail -1) + debug code "$code" + if [[ "$code" == 4* && $response != *"error:badNonce"* ]]; then + detail=$(echo "$response" | grep "detail") + error_exit "ACME server returned error: ${code}: ${detail}" + fi + + if [[ $API -eq 1 ]]; then + response_status=$(json_get "$response" status \ + | head -1| awk -F'"' '{print $2}') + else # APIv2 + if [[ "$outfile" && "$response" ]]; then + debug "response written to $outfile" + elif [[ ${response##*()} == "{"* ]]; then + response_status=$(json_get "$response" status) + else + debug "response not in json format" + debug "$response" + fi + fi + debug "response status = $response_status" + if [[ "$code" -eq 500 ]]; then + info "error on acme server - trying again ...." + debug "loop_limit = $loop_limit" + sleep 5 + loop_limit=$((loop_limit - 1)) + if [[ $loop_limit -lt 1 ]]; then + error_exit "500 error from ACME server: $response" + fi + fi + if [[ "$code" -eq 429 ]]; then + error_exit "429 rate limited error from ACME server" + fi + done + if [[ $response == *"error:badNonce"* ]]; then + debug "bad nonce" + nonce=$(echo "$responseHeaders" | grep -i "^replay-nonce:" | awk '{print $2}' | tr -d '\r\n ') + debug "trying new nonce $nonce" + else + nonceproblem="false" + fi + done +} + +sign_string() { # sign a string with a given key and algorithm and return urlbase64 + # sets the result in variable signed64 + str=$1 + key=$2 + signalg=$3 + + if openssl rsa -in "${skey}" -noout 2>/dev/null ; then # RSA key + signed64="$(printf '%s' "${str}" | openssl dgst -"$signalg" -sign "$key" | urlbase64)" + elif openssl ec -in "${skey}" -noout 2>/dev/null ; then # Elliptic curve key. + signed=$(printf '%s' "${str}" | openssl dgst -"$signalg" -sign "$key" -hex | awk '{print $2}') + debug "EC signature $signed" + if [[ "${signed:4:4}" == "0220" ]]; then #sha256 + R=$(echo "$signed" | cut -c 9-72) + part2=$(echo "$signed" | cut -c 73-) + elif [[ "${signed:4:4}" == "0221" ]]; then #sha256 + R=$(echo "$signed" | cut -c 11-74) + part2=$(echo "$signed" | cut -c 75-) + elif [[ "${signed:4:4}" == "0230" ]]; then #sha384 + R=$(echo "$signed" | cut -c 9-104) + part2=$(echo "$signed" | cut -c 105-) + elif [[ "${signed:4:4}" == "0231" ]]; then #sha384 + R=$(echo "$signed" | cut -c 11-106) + part2=$(echo "$signed" | cut -c 107-) + elif [[ "${signed:6:4}" == "0241" ]]; then #sha512 + R=$(echo "$signed" | cut -c 11-140) + part2=$(echo "$signed" | cut -c 141-) + elif [[ "${signed:6:4}" == "0242" ]]; then #sha512 + R=$(echo "$signed" | cut -c 11-142) + part2=$(echo "$signed" | cut -c 143-) + else + error_exit "error in EC signing couldn't get R from $signed" + fi + debug "R $R" + + if [[ "${part2:0:4}" == "0220" ]]; then #sha256 + S=$(echo "$part2" | cut -c 5-68) + elif [[ "${part2:0:4}" == "0221" ]]; then #sha256 + S=$(echo "$part2" | cut -c 7-70) + elif [[ "${part2:0:4}" == "0230" ]]; then #sha384 + S=$(echo "$part2" | cut -c 5-100) + elif [[ "${part2:0:4}" == "0231" ]]; then #sha384 + S=$(echo "$part2" | cut -c 7-102) + elif [[ "${part2:0:4}" == "0241" ]]; then #sha512 + S=$(echo "$part2" | cut -c 5-136) + elif [[ "${part2:0:4}" == "0242" ]]; then #sha512 + S=$(echo "$part2" | cut -c 5-136) + else + info "print ${str} | openssl dgst -$signalg -sign $key -hex" + error_exit "error in EC signing couldn't get S from $signed" + fi + + debug "S $S" + signed64=$(printf '%s' "${R}${S}" | hex2bin | urlbase64 ) + debug "encoded RS $signed64" + fi +} + +signal_exit() { # Handle trapped signals + case $1 in + INT) + error_exit "Program interrupted by user" ;; + TERM) + echo -e "\n$PROGNAME: Program terminated" >&2 + graceful_exit ;; + *) + error_exit "$PROGNAME: Terminating on unknown signal" ;; + esac +} + +urlbase64() { # urlbase64: base64 encoded string with '+' replaced with '-' and '/' replaced with '_' + openssl base64 -e | tr -d '\n\r' | os_esed -e 's:=*$::g' -e 'y:+/:-_:' +} + +# base64url decode +# From: https://gist.github.com/alvis/89007e96f7958f2686036d4276d28e47 +urlbase64_decode() { + INPUT=$1 # $(if [ -z "$1" ]; then echo -n $(cat -); else echo -n "$1"; fi) + MOD=$(($(echo -n "$INPUT" | wc -c) % 4)) + PADDING=$(if [ $MOD -eq 2 ]; then echo -n '=='; elif [ $MOD -eq 3 ]; then echo -n '=' ; fi) + echo -n "$INPUT$PADDING" | + sed s/-/+/g | + sed s/_/\\//g | + openssl base64 -d -A +} + +usage() { # echos out the program usage + echo "Usage: $PROGNAME [-h|--help] [-d|--debug] [-c|--create] [-f|--force] [-a|--all] [-q|--quiet]"\ + "[-Q|--mute] [-u|--upgrade] [-k|--keep #] [-U|--nocheck] [-r|--revoke cert key] [-w working_dir] domain" +} + +write_domain_template() { # write out a template file for a domain. + cat > "$1" <<- _EOF_domain_ + # Uncomment and modify any variables you need + # see https://github.com/srvrco/getssl/wiki/Config-variables for details + # see https://github.com/srvrco/getssl/wiki/Example-config-files for example configs + # + # The staging server is best for testing + #CA="https://acme-staging-v02.api.letsencrypt.org/directory" + # This server issues full certificates, however has rate limits + #CA="https://acme-v02.api.letsencrypt.org" + + #PRIVATE_KEY_ALG="rsa" + + # Additional domains - this could be multiple domains / subdomains in a comma separated list + # Note: this is Additional domains - so should not include the primary domain. + SANS="${EX_SANS}" + + # Acme Challenge Location. The first line for the domain, the following ones for each additional domain. + # If these start with ssh: then the next variable is assumed to be the hostname and the rest the location. + # An ssh key will be needed to provide you with access to the remote server. + # Optionally, you can specify a different userid for ssh/scp to use on the remote server before the @ sign. + # If left blank, the username on the local server will be used to authenticate against the remote server. + # If these start with ftp: then the next variables are ftpuserid:ftppassword:servername:ACL_location + # These should be of the form "/path/to/your/website/folder/.well-known/acme-challenge" + # where "/path/to/your/website/folder/" is the path, on your web server, to the web root for your domain. + # You can also user WebDAV over HTTPS as transport mechanism. To do so, start with davs: followed by username, + # password, host, port (explicitly needed even if using default port 443) and path on the server. + #ACL=('/var/www/${DOMAIN}/web/.well-known/acme-challenge' + # 'ssh:server5:/var/www/${DOMAIN}/web/.well-known/acme-challenge' + # 'ssh:sshuserid@server5:/var/www/${DOMAIN}/web/.well-known/acme-challenge' + # 'ftp:ftpuserid:ftppassword:${DOMAIN}:/web/.well-known/acme-challenge' + # 'davs:davsuserid:davspassword:{DOMAIN}:443:/web/.well-known/acme-challenge') + + #Set USE_SINGLE_ACL="true" to use a single ACL for all checks + #USE_SINGLE_ACL="false" + + # Location for all your certs, these can either be on the server (full path name) + # or using ssh /sftp as for the ACL + #DOMAIN_CERT_LOCATION="/etc/ssl/${DOMAIN}.crt" # this is domain cert + #DOMAIN_KEY_LOCATION="/etc/ssl/${DOMAIN}.key" # this is domain key + #CA_CERT_LOCATION="/etc/ssl/chain.crt" # this is CA cert + #DOMAIN_CHAIN_LOCATION="" # this is the domain cert and CA cert + #DOMAIN_PEM_LOCATION="" # this is the domain key, domain cert and CA cert + + # The command needed to reload apache / nginx or whatever you use + #RELOAD_CMD="" + + # Define the server type. This can be https, ftp, ftpi, imap, imaps, pop3, pop3s, smtp, + # smtps_deprecated, smtps, smtp_submission, xmpp, xmpps, ldaps or a port number which + # will be checked for certificate expiry and also will be checked after + # an update to confirm correct certificate is running (if CHECK_REMOTE) is set to true + #SERVER_TYPE="https" + #CHECK_REMOTE="true" + #CHECK_REMOTE_WAIT="2" # wait 2 seconds before checking the remote server + _EOF_domain_ +} + +write_getssl_template() { # write out the main template file + cat > "$1" <<- _EOF_getssl_ + # Uncomment and modify any variables you need + # see https://github.com/srvrco/getssl/wiki/Config-variables for details + # + # The staging server is best for testing (hence set as default) + CA="https://acme-staging-v02.api.letsencrypt.org/directory" + # This server issues full certificates, however has rate limits + #CA="https://acme-v02.api.letsencrypt.org" + + #AGREEMENT="$AGREEMENT" + + # Set an email address associated with your account - generally set at account level rather than domain. + #ACCOUNT_EMAIL="me@example.com" + ACCOUNT_KEY_LENGTH=4096 + ACCOUNT_KEY="$WORKING_DIR/account.key" + PRIVATE_KEY_ALG="rsa" + #REUSE_PRIVATE_KEY="true" + + # The command needed to reload apache / nginx or whatever you use + #RELOAD_CMD="" + # The time period within which you want to allow renewal of a certificate + # this prevents hitting some of the rate limits. + RENEW_ALLOW="30" + + # Define the server type. This can be https, ftp, ftpi, imap, imaps, pop3, pop3s, smtp, + # smtps_deprecated, smtps, smtp_submission, xmpp, xmpps, ldaps or a port number which + # will be checked for certificate expiry and also will be checked after + # an update to confirm correct certificate is running (if CHECK_REMOTE) is set to true + SERVER_TYPE="https" + CHECK_REMOTE="true" + + # Use the following 3 variables if you want to validate via DNS + #VALIDATE_VIA_DNS="true" + #DNS_ADD_COMMAND= + #DNS_DEL_COMMAND= + _EOF_getssl_ +} + +write_openssl_conf() { # write out a minimal openssl conf + cat > "$1" <<- _EOF_openssl_conf_ + # minimal openssl.cnf file + distinguished_name = req_distinguished_name + [ req_distinguished_name ] + [v3_req] + [v3_ca] + _EOF_openssl_conf_ +} + +# Trap signals +trap "signal_exit TERM" TERM HUP +trap "signal_exit INT" INT + +# Parse command-line +while [[ -n ${1+defined} ]]; do + case $1 in + -h | --help) + help_message; graceful_exit ;; + -d | --debug) + _USE_DEBUG=1 ;; + -c | --create) + _CREATE_CONFIG=1 ;; + -f | --force) + _FORCE_RENEW=1 ;; + -a | --all) + _CHECK_ALL=1 ;; + -k | --keep) + shift; _KEEP_VERSIONS="$1";; + -q | --quiet) + _QUIET=1 ;; + -Q | --mute) + _QUIET=1 + _MUTE=1 ;; + -r | --revoke) + _REVOKE=1 + shift + REVOKE_CERT="$1" + shift + REVOKE_KEY="$1" + shift + REVOKE_CA="$1" ;; + -u | --upgrade) + _UPGRADE=1 ;; + -U | --nocheck) + _UPGRADE_CHECK=0 ;; + -w) + shift; WORKING_DIR="$1" ;; + -*) + usage + error_exit "Unknown option $1" ;; + *) + if [[ -n $DOMAIN ]]; then + error_exit "invalid command line $DOMAIN - it appears to contain more than one domain" + fi + DOMAIN="$1" + if [[ -z $DOMAIN ]]; then + error_exit "invalid command line - it appears to contain a null variable" + fi ;; + esac + shift +done + +# Main logic +############ + +# Get the current OS, so the correct functions can be used for that OS. (sets the variable os) +get_os + +# check if "recent" version of bash. +#if [[ "${BASH_VERSINFO[0]}${BASH_VERSINFO[1]}" -lt 42 ]]; then +# info "this script is designed for bash v4.2 or later - earlier version may give errors" +#fi + +#check if required applications are included + +requires which +requires openssl +requires curl +requires nslookup drill dig host DNS_CHECK_FUNC +requires awk +requires tr +requires date +requires grep +requires sed +requires sort +requires mktemp + +# Check if upgrades are available (unless they have specified -U to ignore Upgrade checks) +if [[ $_UPGRADE_CHECK -eq 1 ]]; then + check_getssl_upgrade +fi + +# Revoke a certificate if requested +if [[ $_REVOKE -eq 1 ]]; then + if [[ -z $REVOKE_CA ]]; then + CA=$DEFAULT_REVOKE_CA + elif [[ "$REVOKE_CA" == "-d" ]]; then + _USE_DEBUG=1 + CA=$DEFAULT_REVOKE_CA + else + CA=$REVOKE_CA + fi + URL_revoke=$(curl --user-agent "$CURL_USERAGENT" "${CA}/directory" 2>/dev/null | grep "revoke-cert" | awk -F'"' '{print $4}') + revoke_certificate + graceful_exit +fi + +# get latest agreement from CA (as default) +AGREEMENT=$(curl --user-agent "$CURL_USERAGENT" -I "${CA}/terms" 2>/dev/null | awk 'tolower($1) ~ "location:" {print $2}'|tr -d '\r') + +# if nothing in command line, print help and exit. +if [[ -z "$DOMAIN" ]] && [[ ${_CHECK_ALL} -ne 1 ]]; then + help_message + graceful_exit +fi + +# if the "working directory" doesn't exist, then create it. +if [[ ! -d "$WORKING_DIR" ]]; then + debug "Making working directory - $WORKING_DIR" + mkdir -p "$WORKING_DIR" +fi + +# read any variables from config in working directory +if [[ -s "$WORKING_DIR/getssl.cfg" ]]; then + debug "reading config from $WORKING_DIR/getssl.cfg" + # shellcheck source=/dev/null + . "$WORKING_DIR/getssl.cfg" +fi + +# Define defaults for variables not set in the main config. +ACCOUNT_KEY="${ACCOUNT_KEY:=$WORKING_DIR/account.key}" +DOMAIN_STORAGE="${DOMAIN_STORAGE:=$WORKING_DIR}" +DOMAIN_DIR="$DOMAIN_STORAGE/$DOMAIN" +CERT_FILE="$DOMAIN_DIR/${DOMAIN}.crt" +FULL_CHAIN="$DOMAIN_DIR/fullchain.crt" +CA_CERT="$DOMAIN_DIR/chain.crt" +TEMP_DIR="$DOMAIN_DIR/tmp" +if [[ "$os" == "mingw" ]]; then + CSR_SUBJECT="//" +fi + +# Set the OPENSSL_CONF environment variable so openssl knows which config to use +export OPENSSL_CONF=$SSLCONF + +# if "-a" option then check other parameters and create run for each domain. +if [[ ${_CHECK_ALL} -eq 1 ]]; then + info "Check all certificates" + + if [[ ${_CREATE_CONFIG} -eq 1 ]]; then + error_exit "cannot combine -c|--create with -a|--all" + fi + + if [[ ${_FORCE_RENEW} -eq 1 ]]; then + error_exit "cannot combine -f|--force with -a|--all because of rate limits" + fi + + if [[ ! -d "$DOMAIN_STORAGE" ]]; then + error_exit "DOMAIN_STORAGE not found - $DOMAIN_STORAGE" + fi + + for dir in "${DOMAIN_STORAGE}"/*; do + if [[ -d "$dir" ]]; then + debug "Checking $dir" + cmd="$0 -U" # No update checks when calling recursively + if [[ ${_USE_DEBUG} -eq 1 ]]; then + cmd="$cmd -d" + fi + if [[ ${_QUIET} -eq 1 ]]; then + cmd="$cmd -q" + fi + # check if $dir is a directory with a getssl.cfg in it + if [[ -f "$dir/getssl.cfg" ]]; then + cmd="$cmd -w $WORKING_DIR $(basename "$dir")" + debug "CMD: $cmd" + eval "$cmd" + fi + fi + done + + graceful_exit +fi +# end of "-a" option (looping through all domains) + +# if "-c|--create" option used, then create config files. +if [[ ${_CREATE_CONFIG} -eq 1 ]]; then + # If main config file does not exists then create it. + if [[ ! -s "$WORKING_DIR/getssl.cfg" ]]; then + info "creating main config file $WORKING_DIR/getssl.cfg" + if [[ ! -s "$SSLCONF" ]]; then + SSLCONF="$WORKING_DIR/openssl.cnf" + write_openssl_conf "$SSLCONF" + fi + write_getssl_template "$WORKING_DIR/getssl.cfg" + fi + # If domain and domain config don't exist then create them. + if [[ ! -d "$DOMAIN_DIR" ]]; then + info "Making domain directory - $DOMAIN_DIR" + mkdir -p "$DOMAIN_DIR" + fi + if [[ -s "$DOMAIN_DIR/getssl.cfg" ]]; then + info "domain config already exists $DOMAIN_DIR/getssl.cfg" + else + info "creating domain config file in $DOMAIN_DIR/getssl.cfg" + # if domain has an existing cert, copy from domain and use to create defaults. + EX_CERT=$(echo \ + | openssl s_client -servername "${DOMAIN}" -connect "${DOMAIN}:443" 2>/dev/null \ + | openssl x509 2>/dev/null) + EX_SANS="www.${DOMAIN}" + if [[ -n "${EX_CERT}" ]]; then + EX_SANS=$(echo "$EX_CERT" \ + | openssl x509 -noout -text 2>/dev/null| grep "Subject Alternative Name" -A2 \ + | grep -Eo "DNS:[a-zA-Z 0-9.-]*" | sed "s@DNS:$DOMAIN@@g" | grep -v '^$' | cut -c 5-) + EX_SANS=${EX_SANS//$'\n'/','} + fi + write_domain_template "$DOMAIN_DIR/getssl.cfg" + fi + TEMP_DIR="$DOMAIN_DIR/tmp" + # end of "-c|--create" option, so exit + graceful_exit +fi +# end of "-c|--create" option to create config file. + +# if domain directory doesn't exist, then create it. +if [[ ! -d "$DOMAIN_DIR" ]]; then + debug "Making working directory - $DOMAIN_DIR" + mkdir -p "$DOMAIN_DIR" +fi + +# define a temporary directory, and if it doesn't exist, create it. +TEMP_DIR="$DOMAIN_DIR/tmp" +if [[ ! -d "${TEMP_DIR}" ]]; then + debug "Making temp directory - ${TEMP_DIR}" + mkdir -p "${TEMP_DIR}" +fi + +# read any variables from config in domain directory +if [[ -s "$DOMAIN_DIR/getssl.cfg" ]]; then + debug "reading config from $DOMAIN_DIR/getssl.cfg" + # shellcheck source=/dev/null + . "$DOMAIN_DIR/getssl.cfg" +fi + +# from SERVER_TYPE set REMOTE_PORT and REMOTE_EXTRA +set_server_type + +# check config for typical errors. +check_config + +if [[ -e "$DOMAIN_DIR/FORCE_RENEWAL" ]]; then + rm -f "$DOMAIN_DIR/FORCE_RENEWAL" || error_exit "problem deleting file $DOMAIN_DIR/FORCE_RENEWAL" + _FORCE_RENEW=1 + info "${DOMAIN}: forcing renewal (due to FORCE_RENEWAL file)" +fi + +# Obtain CA resource locations +ca_all_loc=$(curl --user-agent "$CURL_USERAGENT" "${CA}" 2>/dev/null) +debug "ca_all_loc from ${CA} gives $ca_all_loc" +# APIv1 +URL_new_reg=$(echo "$ca_all_loc" | grep "new-reg" | awk -F'"' '{print $4}') +URL_new_authz=$(echo "$ca_all_loc" | grep "new-authz" | awk -F'"' '{print $4}') +URL_new_cert=$(echo "$ca_all_loc" | grep "new-cert" | awk -F'"' '{print $4}') +#API v2 +URL_newAccount=$(echo "$ca_all_loc" | grep "newAccount" | awk -F'"' '{print $4}') +URL_newNonce=$(echo "$ca_all_loc" | grep "newNonce" | awk -F'"' '{print $4}') +URL_newOrder=$(echo "$ca_all_loc" | grep "newOrder" | awk -F'"' '{print $4}') +if [[ -z "$URL_new_reg" ]] && [[ -z "$URL_newAccount" ]]; then + ca_all_loc=$(curl --user-agent "$CURL_USERAGENT" "${CA}/directory" 2>/dev/null) + debug "ca_all_loc from ${CA}/directory gives $ca_all_loc" + # APIv1 + URL_new_reg=$(echo "$ca_all_loc" | grep "new-reg" | awk -F'"' '{print $4}') + URL_new_authz=$(echo "$ca_all_loc" | grep "new-authz" | awk -F'"' '{print $4}') + URL_new_cert=$(echo "$ca_all_loc" | grep "new-cert" | awk -F'"' '{print $4}') + #API v2 + URL_newAccount=$(echo "$ca_all_loc" | grep "newAccount" | awk -F'"' '{print $4}') + URL_newNonce=$(echo "$ca_all_loc" | grep "newNonce" | awk -F'"' '{print $4}') + URL_newOrder=$(echo "$ca_all_loc" | grep "newOrder" | awk -F'"' '{print $4}') +fi + +if [[ -n "$URL_new_reg" ]]; then + API=1 +elif [[ -n "$URL_newAccount" ]]; then + API=2 +else + info "unknown API version" + graceful_exit +fi +debug "Using API v$API" + +# Check if awk supports json_awk (required for ACMEv2) +if [[ $API -eq 2 ]]; then + json_awk_test=$(json_awk '{ "test": "1" }' 2>/dev/null) + if [[ "${json_awk_test}" == "" ]]; then + error_exit "Your version of awk does not work with json_awk (see http://github.com/step-/JSON.awk/issues/6), please install a newer version of mawk or gawk" + fi +fi + +# if check_remote is true then connect and obtain the current certificate (if not forcing renewal) +if [[ "${CHECK_REMOTE}" == "true" ]] && [[ $_FORCE_RENEW -eq 0 ]]; then + debug "getting certificate for $DOMAIN from remote server" + # shellcheck disable=SC2086 + EX_CERT=$(echo \ + | openssl s_client -servername "${DOMAIN}" -connect "${DOMAIN}:${REMOTE_PORT}" ${REMOTE_EXTRA} 2>/dev/null \ + | openssl x509 2>/dev/null) + if [[ -n "$EX_CERT" ]]; then # if obtained a cert + if [[ -s "$CERT_FILE" ]]; then # if local exists + CERT_LOCAL=$(openssl x509 -noout -fingerprint < "$CERT_FILE" 2>/dev/null) + else # since local doesn't exist leave empty so that the domain validation will happen + CERT_LOCAL="" + fi + CERT_REMOTE=$(echo "$EX_CERT" | openssl x509 -noout -fingerprint 2>/dev/null) + if [[ "$CERT_LOCAL" == "$CERT_REMOTE" ]]; then + debug "certificate on server is same as the local cert" + else + # check if the certificate is for the right domain + EX_CERT_DOMAIN=$(echo "$EX_CERT" | openssl x509 -text \ + | sed -n -e 's/^ *Subject: .* CN=\([A-Za-z0-9.-]*\).*$/\1/p; /^ *DNS:.../ { s/ *DNS://g; y/,/\n/; p; }' \ + | sort -u | grep "^$DOMAIN\$") + if [[ "$EX_CERT_DOMAIN" == "$DOMAIN" ]]; then + # check renew-date on ex_cert and compare to local ( if local exists) + enddate_ex=$(echo "$EX_CERT" | openssl x509 -noout -enddate 2>/dev/null| cut -d= -f 2-) + enddate_ex_s=$(date_epoc "$enddate_ex") + debug "external cert has enddate $enddate_ex ( $enddate_ex_s ) " + if [[ -s "$CERT_FILE" ]]; then # if local exists + enddate_lc=$(openssl x509 -noout -enddate < "$CERT_FILE" 2>/dev/null| cut -d= -f 2-) + enddate_lc_s=$(date_epoc "$enddate_lc") + debug "local cert has enddate $enddate_lc ( $enddate_lc_s ) " + else + enddate_lc_s=0 + debug "local cert doesn't exist" + fi + if [[ "$enddate_ex_s" -eq "$enddate_lc_s" ]]; then + debug "certificates expire at the same time" + elif [[ "$enddate_ex_s" -gt "$enddate_lc_s" ]]; then + # remote has longer to expiry date than local copy. + debug "remote cert has longer to run than local cert - ignoring" + else + info "${DOMAIN}: remote cert expires sooner than local, attempting to upload from local" + copy_file_to_location "domain certificate" \ + "$CERT_FILE" \ + "$DOMAIN_CERT_LOCATION" + copy_file_to_location "private key" \ + "$DOMAIN_DIR/${DOMAIN}.key" \ + "$DOMAIN_KEY_LOCATION" + copy_file_to_location "CA certificate" "$CA_CERT" "$CA_CERT_LOCATION" + cat "$CERT_FILE" "$CA_CERT" > "$TEMP_DIR/${DOMAIN}_chain.pem" + copy_file_to_location "full pem" \ + "$TEMP_DIR/${DOMAIN}_chain.pem" \ + "$DOMAIN_CHAIN_LOCATION" + cat "$DOMAIN_DIR/${DOMAIN}.key" "$CERT_FILE" > "$TEMP_DIR/${DOMAIN}_K_C.pem" + copy_file_to_location "private key and domain cert pem" \ + "$TEMP_DIR/${DOMAIN}_K_C.pem" \ + "$DOMAIN_KEY_CERT_LOCATION" + cat "$DOMAIN_DIR/${DOMAIN}.key" "$CERT_FILE" "$CA_CERT" > "$TEMP_DIR/${DOMAIN}.pem" + copy_file_to_location "full pem" \ + "$TEMP_DIR/${DOMAIN}.pem" \ + "$DOMAIN_PEM_LOCATION" + reload_service + fi + else + info "${DOMAIN}: Certificate on remote domain does not match, ignoring remote certificate" + fi + fi + else + info "${DOMAIN}: no certificate obtained from host" + fi + # end of .... if obtained a cert +fi +# end of .... check_remote is true then connect and obtain the current certificate + +# if there is an existing certificate file, check details. +if [[ -s "$CERT_FILE" ]]; then + debug "certificate $CERT_FILE exists" + enddate=$(openssl x509 -in "$CERT_FILE" -noout -enddate 2>/dev/null| cut -d= -f 2-) + debug "local cert is valid until $enddate" + if [[ "$enddate" != "-" ]]; then + enddate_s=$(date_epoc "$enddate") + if [[ $(date_renew) -lt "$enddate_s" ]] && [[ $_FORCE_RENEW -ne 1 ]]; then + issuer=$(openssl x509 -in "$CERT_FILE" -noout -issuer 2>/dev/null) + if [[ "$issuer" == *"Fake LE Intermediate"* ]] && [[ "$CA" == "https://acme-v02.api.letsencrypt.org" ]]; then + debug "upgrading from fake cert to real" + else + info "${DOMAIN}: certificate is valid for more than $RENEW_ALLOW days (until $enddate)" + # everything is OK, so exit. + graceful_exit + fi + else + debug "${DOMAIN}: certificate needs renewal" + fi + fi +fi +# end of .... if there is an existing certificate file, check details. + +if [[ ! -t 0 ]] && [[ "$PREVENT_NON_INTERACTIVE_RENEWAL" = "true" ]]; then + errmsg="$DOMAIN due for renewal," + errmsg="${errmsg} but not completed due to PREVENT_NON_INTERACTIVE_RENEWAL=true in config" + error_exit "$errmsg" +fi + +# create account key if it doesn't exist. +if [[ -s "$ACCOUNT_KEY" ]]; then + debug "Account key exists at $ACCOUNT_KEY skipping generation" +else + info "creating account key $ACCOUNT_KEY" + create_key "$ACCOUNT_KEY_TYPE" "$ACCOUNT_KEY" "$ACCOUNT_KEY_LENGTH" +fi + +# if not reusing private key, then remove the old keys +if [[ "$REUSE_PRIVATE_KEY" != "true" ]]; then + if [[ -s "$DOMAIN_DIR/${DOMAIN}.key" ]]; then + rm -f "$DOMAIN_DIR/${DOMAIN}.key" + fi + if [[ -s "$DOMAIN_DIR/${DOMAIN}.ec.key" ]]; then + rm -f "$DOMAIN_DIR/${DOMAIN}.ecs.key" + fi +fi +# create new domain keys if they don't already exist +if [[ "$DUAL_RSA_ECDSA" == "false" ]]; then + create_key "${PRIVATE_KEY_ALG}" "$DOMAIN_DIR/${DOMAIN}.key" "$DOMAIN_KEY_LENGTH" +else + create_key "rsa" "$DOMAIN_DIR/${DOMAIN}.key" "$DOMAIN_KEY_LENGTH" + create_key "${PRIVATE_KEY_ALG}" "$DOMAIN_DIR/${DOMAIN}.ec.key" "$DOMAIN_KEY_LENGTH" +fi +# End of creating domain keys. + +#create SAN +if [[ -z "$SANS" ]]; then + SANLIST="subjectAltName=DNS:${DOMAIN}" +elif [[ "$IGNORE_DIRECTORY_DOMAIN" == "true" ]]; then + SANLIST="subjectAltName=DNS:${SANS//,/,DNS:}" +else + SANLIST="subjectAltName=DNS:${DOMAIN},DNS:${SANS//,/,DNS:}" +fi +debug "created SAN list = $SANLIST" + +#create CSR's +if [[ "$DUAL_RSA_ECDSA" == "false" ]]; then + create_csr "$DOMAIN_DIR/${DOMAIN}.csr" "$DOMAIN_DIR/${DOMAIN}.key" +else + create_csr "$DOMAIN_DIR/${DOMAIN}.csr" "$DOMAIN_DIR/${DOMAIN}.key" + create_csr "$DOMAIN_DIR/${DOMAIN}.ec.csr" "$DOMAIN_DIR/${DOMAIN}.ec.key" +fi + +# use account key to register with CA +# currently the code registers every time, and gets an "already registered" back if it has been. +get_signing_params "$ACCOUNT_KEY" + +info "Registering account" +# send the request to the ACME server. +if [[ $API -eq 1 ]]; then + if [[ "$ACCOUNT_EMAIL" ]] ; then + regjson='{"resource": "new-reg", "contact": ["mailto: '$ACCOUNT_EMAIL'"], "agreement": "'$AGREEMENT'"}' + else + regjson='{"resource": "new-reg", "agreement": "'$AGREEMENT'"}' + fi + send_signed_request "$URL_new_reg" "$regjson" +elif [[ $API -eq 2 ]]; then + if [[ "$ACCOUNT_EMAIL" ]] ; then + regjson='{"termsOfServiceAgreed": true, "contact": ["mailto: '$ACCOUNT_EMAIL'"]}' + else + regjson='{"termsOfServiceAgreed": true}' + fi + send_signed_request "$URL_newAccount" "$regjson" +else + debug "cant determine account API" + graceful_exit +fi + +if [[ "$code" == "" ]] || [[ "$code" == '201' ]] ; then + info "Registered" + KID=$(echo "$responseHeaders" | grep -i "^location" | awk '{print $2}'| tr -d '\r\n ') + debug "KID=_$KID}_" + echo "$response" > "$TEMP_DIR/account.json" +elif [[ "$code" == '409' ]] ; then + KID=$(echo "$responseHeaders" | grep -i "^location" | awk '{print $2}'| tr -d '\r\n ') + debug responseHeaders "$responseHeaders" + debug "Already registered KID=$KID" +elif [[ "$code" == '200' ]] ; then + KID=$(echo "$responseHeaders" | grep -i "^location" | awk '{print $2}'| tr -d '\r\n ') + debug responseHeaders "$responseHeaders" + debug "Already registered account, KID=${KID}" +else + error_exit "Error registering account ...$responseHeaders ... $(json_get "$response" detail)" +fi +# end of registering account with CA + +# verify each domain +info "Verify each domain" + +# loop through domains for cert ( from SANS list) +if [[ "$IGNORE_DIRECTORY_DOMAIN" == "true" ]]; then + alldomains=${SANS//,/ } +else + alldomains=$(echo "$DOMAIN,$SANS" | sed "s/,/ /g") +fi + +if [[ $API -eq 2 ]]; then + create_order +fi + +fulfill_challenges + +# Verification has been completed for all SANS, so request certificate. +info "Verification completed, obtaining certificate." + +#obtain the certificate. +get_certificate "$DOMAIN_DIR/${DOMAIN}.csr" \ + "$CERT_FILE" \ + "$CA_CERT" +if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then + info "Creating order for EC certificate" + if [[ $API -eq 2 ]]; then + create_order + fulfill_challenges + fi + info "obtaining EC certificate." + get_certificate "$DOMAIN_DIR/${DOMAIN}.ec.csr" \ + "${CERT_FILE%.*}.ec.crt" \ + "${CA_CERT%.*}.ec.crt" +fi + +# create Archive of new certs and keys. +cert_archive + +debug "Certificates obtained and archived locally, will now copy to specified locations" + +# copy certs to the correct location (creating concatenated files as required) +umask 077 + +copy_file_to_location "domain certificate" "$CERT_FILE" "$DOMAIN_CERT_LOCATION" +copy_file_to_location "private key" "$DOMAIN_DIR/${DOMAIN}.key" "$DOMAIN_KEY_LOCATION" +copy_file_to_location "CA certificate" "$CA_CERT" "$CA_CERT_LOCATION" +if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then + if [[ -n "$DOMAIN_CERT_LOCATION" ]]; then + copy_file_to_location "ec domain certificate" \ + "${CERT_FILE%.*}.ec.crt" \ + "${DOMAIN_CERT_LOCATION%.*}.ec.crt" + fi + if [[ -n "$DOMAIN_KEY_LOCATION" ]]; then + copy_file_to_location "ec private key" \ + "$DOMAIN_DIR/${DOMAIN}.ec.key" \ + "${DOMAIN_KEY_LOCATION%.*}.ec.key" + fi + if [[ -n "$CA_CERT_LOCATION" ]]; then + copy_file_to_location "ec CA certificate" \ + "${CA_CERT%.*}.ec.crt" \ + "${CA_CERT_LOCATION%.*}.ec.crt" + fi +fi + +# if DOMAIN_CHAIN_LOCATION is not blank, then create and copy file. +if [[ -n "$DOMAIN_CHAIN_LOCATION" ]]; then + if [[ "$(dirname "$DOMAIN_CHAIN_LOCATION")" == "." ]]; then + to_location="${DOMAIN_DIR}/${DOMAIN_CHAIN_LOCATION}" + else + to_location="${DOMAIN_CHAIN_LOCATION}" + fi + cat "$CERT_FILE" "$CA_CERT" > "$TEMP_DIR/${DOMAIN}_chain.pem" + copy_file_to_location "full chain" "$TEMP_DIR/${DOMAIN}_chain.pem" "$to_location" + if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then + cat "${CERT_FILE%.*}.ec.crt" "${CA_CERT%.*}.ec.crt" > "$TEMP_DIR/${DOMAIN}_chain.pem.ec" + copy_file_to_location "full chain" "$TEMP_DIR/${DOMAIN}_chain.pem.ec" "${to_location}.ec" + fi +fi +# if DOMAIN_KEY_CERT_LOCATION is not blank, then create and copy file. +if [[ -n "$DOMAIN_KEY_CERT_LOCATION" ]]; then + if [[ "$(dirname "$DOMAIN_KEY_CERT_LOCATION")" == "." ]]; then + to_location="${DOMAIN_DIR}/${DOMAIN_KEY_CERT_LOCATION}" + else + to_location="${DOMAIN_KEY_CERT_LOCATION}" + fi + cat "$DOMAIN_DIR/${DOMAIN}.key" "$CERT_FILE" > "$TEMP_DIR/${DOMAIN}_K_C.pem" + copy_file_to_location "private key and domain cert pem" "$TEMP_DIR/${DOMAIN}_K_C.pem" "$to_location" + if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then + cat "$DOMAIN_DIR/${DOMAIN}.ec.key" "${CERT_FILE%.*}.ec.crt" > "$TEMP_DIR/${DOMAIN}_K_C.pem.ec" + copy_file_to_location "private ec key and domain cert pem" "$TEMP_DIR/${DOMAIN}_K_C.pem.ec" "${to_location}.ec" + fi +fi +# if DOMAIN_PEM_LOCATION is not blank, then create and copy file. +if [[ -n "$DOMAIN_PEM_LOCATION" ]]; then + if [[ "$(dirname "$DOMAIN_PEM_LOCATION")" == "." ]]; then + to_location="${DOMAIN_DIR}/${DOMAIN_PEM_LOCATION}" + else + to_location="${DOMAIN_PEM_LOCATION}" + fi + cat "$DOMAIN_DIR/${DOMAIN}.key" "$CERT_FILE" "$CA_CERT" > "$TEMP_DIR/${DOMAIN}.pem" + copy_file_to_location "full key, cert and chain pem" "$TEMP_DIR/${DOMAIN}.pem" "$to_location" + if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then + cat "$DOMAIN_DIR/${DOMAIN}.ec.key" "${CERT_FILE%.*}.ec.crt" "${CA_CERT%.*}.ec.crt" > "$TEMP_DIR/${DOMAIN}.pem.ec" + copy_file_to_location "full ec key, cert and chain pem" "$TEMP_DIR/${DOMAIN}.pem.ec" "${to_location}.ec" + fi +fi +# end of copying certs. +umask "$ORIG_UMASK" +# Run reload command to restart apache / nginx or whatever system +reload_service + +# deactivate authorizations +if [[ "$DEACTIVATE_AUTH" == "true" ]]; then + debug "in deactivate list is $deactivate_url_list" + for deactivate_url in $deactivate_url_list; do + send_signed_request "$deactivate_url" "" + d=$(json_get "$response" "hostname") + info "deactivating domain $d" + debug "deactivating $deactivate_url" + send_signed_request "$deactivate_url" "{\"resource\": \"authz\", \"status\": \"deactivated\"}" + # check response + if [[ "$code" == "200" ]]; then + debug "Authorization deactivated" + else + error_exit "$domain: Deactivation error: $code" + fi + done +fi +# end of deactivating authorizations + +# Check if the certificate is installed correctly +if [[ ${CHECK_REMOTE} == "true" ]]; then + sleep "$CHECK_REMOTE_WAIT" + # shellcheck disable=SC2086 + CERT_REMOTE=$(echo \ + | openssl s_client -servername "${DOMAIN}" -connect "${DOMAIN}:${REMOTE_PORT}" ${REMOTE_EXTRA} 2>/dev/null \ + | openssl x509 -noout -fingerprint 2>/dev/null) + CERT_LOCAL=$(openssl x509 -noout -fingerprint < "$CERT_FILE" 2>/dev/null) + if [[ "$CERT_LOCAL" == "$CERT_REMOTE" ]]; then + info "${DOMAIN} - certificate installed OK on server" + else + error_exit "${DOMAIN} - certificate obtained but certificate on server is different from the new certificate" + fi +fi +# end of Check if the certificate is installed correctly + +# To have reached here, a certificate should have been successfully obtained. +# Use echo rather than info so that 'quiet' is ignored. +echo "certificate obtained for ${DOMAIN}" + +# gracefully exit ( tidying up temporary files etc). +graceful_exit diff --git a/getssl.new b/getssl.new new file mode 100644 index 0000000..dd07a94 --- /dev/null +++ b/getssl.new @@ -0,0 +1,2570 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# getssl - Obtain SSL certificates from the letsencrypt.org ACME server + +# 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, either version 3 of the License, or +# (at your option) any later version. + +# 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 at for +# more details. + +# For usage, run "getssl -h" or see https://github.com/srvrco/getssl + +# Revision history: +# 2016-01-08 Created (v0.1) +# 2016-01-11 type correction and upload to github (v0.2) +# 2016-01-11 added import of any existing cert on -c option (v0.3) +# 2016-01-12 corrected formatting of imported certificate (v0.4) +# 2016-01-12 corrected error on removal of token in some instances (v0.5) +# 2016-01-18 corrected issue with removing tmp if run as root with the -c option (v0.6) +# 2016-01-18 added option to upload a single PEN file ( used by cpanel) (v0.7) +# 2016-01-23 added dns challenge option (v0.8) +# 2016-01-24 create the ACL directory if it does not exist. (v0.9) - dstosberg +# 2016-01-26 correcting a couple of small bugs and allow curl to follow redirects (v0.10) +# 2016-01-27 add a very basic openssl.cnf file if it doesn't exist and tidy code slightly (v0.11) +# 2016-01-28 Typo corrections, quoted file variables and fix bug on DNS_DEL_COMMAND (v0.12) +# 2016-01-28 changed DNS checks to use nslookup and allow hyphen in domain names (v0.13) +# 2016-01-29 Fix ssh-reload-command, extra waiting for DNS-challenge, +# 2016-01-29 add error_exit and cleanup help message (v0.14) +# 2016-01-29 added -a|--all option to renew all configured certificates (v0.15) +# 2016-01-29 added option for elliptic curve keys (v0.16) +# 2016-01-29 added server-type option to use and check cert validity from website (v0.17) +# 2016-01-30 added --quiet option for running in cron (v0.18) +# 2016-01-31 removed usage of xxd to make script more compatible across versions (v0.19) +# 2016-01-31 removed usage of base64 to make script more compatible across platforms (v0.20) +# 2016-01-31 added option to safe a full chain certificate (v0.21) +# 2016-02-01 commented code and added option for copying concatenated certs to file (v0.22) +# 2016-02-01 re-arrange flow for DNS-challenge, to reduce time taken (v0.23) +# 2016-02-04 added options for other server types (ldaps, or any port) and check_remote (v0.24) +# 2016-02-04 added short sleep following service restart before checking certs (v0.25) +# 2016-02-12 fix challenge token location when directory doesn't exist (v0.26) +# 2016-02-17 fix sed -E issue, and reduce length of renew check to 365 days for older systems (v0.27) +# 2016-04-05 Ensure DNS cleanup on error exit. (0.28) - pecigonzalo +# 2016-04-15 Remove NS Lookup of A record when using dns validation (0.29) - pecigonzalo +# 2016-04-17 Improving the wording in a couple of comments and info statements. (0.30) +# 2016-05-04 Improve check for if DNS_DEL_COMMAND is blank. (0.31) +# 2016-05-06 Setting umask to 077 for security of private keys etc. (0.32) +# 2016-05-20 update to reflect changes in staging ACME server json (0.33) +# 2016-05-20 tidying up checking of json following ACME changes. (0.34) +# 2016-05-21 added AUTH_DNS_SERVER to getssl.cfg as optional definition of authoritative DNS server (0.35) +# 2016-05-21 added DNS_WAIT to getssl.cfg as (default = 10 seconds as before) (0.36) +# 2016-05-21 added PUBLIC_DNS_SERVER option, for forcing use of an external DNS server (0.37) +# 2016-05-28 added FTP method of uploading tokens to remote server (blocked for certs as not secure) (0.38) +# 2016-05-28 added FTP method into the default config notes. (0.39) +# 2016-05-30 Add sftp with password to copy files (0.40) +# 2016-05-30 Add version check to see if there is a more recent version of getssl (0.41) +# 2016-05-30 Add [-u|--upgrade] option to automatically upgrade getssl (0.42) +# 2016-05-30 Added backup when auto-upgrading (0.43) +# 2016-05-30 Improvements to auto-upgrade (0.44) +# 2016-05-31 Improved comments - no structural changes +# 2016-05-31 After running for nearly 6 months, final testing prior to a 1.00 stable version. (0.90) +# 2016-06-01 Reorder functions alphabetically as part of code tidy. (0.91) +# 2016-06-03 Version 1.0 of code for release (1.00) +# 2016-06-09 bugfix of issue 44, and add success statement (ignoring quiet flag) (1.01) +# 2016-06-13 test return status of DNS_ADD_COMMAND and error_exit if a problem (hadleyrich) (1.02) +# 2016-06-13 bugfix of issue 45, problem with SERVER_TYPE when it's just a port number (1.03) +# 2016-06-13 bugfix issue 47 - DNS_DEL_COMMAND cleanup was run when not required. (1.04) +# 2016-06-15 add error checking on RELOAD_CMD (1.05) +# 2016-06-20 updated sed and date functions to run on MAC OS X (1.06) +# 2016-06-20 added CHALLENGE_CHECK_TYPE variable to allow checks direct on https rather than http (1.07) +# 2016-06-21 updated grep functions to run on MAC OS X (1.08) +# 2016-06-11 updated to enable running on windows with cygwin (1.09) +# 2016-07-02 Corrections to work with older slackware issue #56 (1.10) +# 2016-07-02 Updating help info re ACL in config file (1.11) +# 2016-07-04 adding DOMAIN_STORAGE as a variable to solve for issue #59 (1.12) +# 2016-07-05 updated order to better handle non-standard DOMAIN_STORAGE location (1.13) +# 2016-07-06 added additional comments about SANS in example template (1.14) +# 2016-07-07 check for duplicate domains in domain / SANS (1.15) +# 2016-07-08 modified to be used on older bash for issue #64 (1.16) +# 2016-07-11 added -w to -a option and comments in domain template (1.17) +# 2016-07-18 remove / regenerate csr when generating new private domain key (1.18) +# 2016-07-21 add output of combined private key and domain cert (1.19) +# 2016-07-21 updated typo (1.20) +# 2016-07-22 corrected issue in nslookup debug option - issue #74 (1.21) +# 2016-07-26 add more server-types based on openssl s_client (1.22) +# 2016-08-01 updated agreement for letsencrypt (1.23) +# 2016-08-02 updated agreement for letsencrypt to update automatically (1.24) +# 2016-08-03 improve messages on test of certificate installation (1.25) +# 2016-08-04 remove carriage return from agreement - issue #80 (1.26) +# 2016-08-04 set permissions for token folders - issue #81 (1.27) +# 2016-08-07 allow default chained file creation - issue #85 (1.28) +# 2016-08-07 use copy rather than move when archiving certs - issue #86 (1.29) +# 2016-08-07 enable use of a single ACL for all checks (if USE_SINGLE_ACL="true" (1.30) +# 2016-08-23 check for already validated domains (issue #93) - (1.31) +# 2016-08-23 updated already validated domains (1.32) +# 2016-08-23 included better force_renew and template for USE_SINGLE_ACL (1.33) +# 2016-08-23 enable insecure certificate on https token check #94 (1.34) +# 2016-08-23 export OPENSSL_CONF so it's used by all openssl commands (1.35) +# 2016-08-25 updated defaults for ACME agreement (1.36) +# 2016-09-04 correct issue #101 when some domains already validated (1.37) +# 2016-09-12 Checks if which is installed (1.38) +# 2016-09-13 Don't check for updates, if -U parameter has been given (1.39) +# 2016-09-17 Improved error messages from invalid certs (1.40) +# 2016-09-19 remove update check on recursive calls when using -a (1.41) +# 2016-09-21 changed shebang for portability (1.42) +# 2016-09-21 Included option to Deactivate an Authorization (1.43) +# 2016-09-22 retry on 500 error from ACME server (1.44) +# 2016-09-22 added additional checks and retry on 500 error from ACME server (1.45) +# 2016-09-24 merged in IPv6 support (1.46) +# 2016-09-27 added additional debug info issue #119 (1.47) +# 2016-09-27 removed IPv6 switch in favour of checking both IPv4 and IPv6 (1.48) +# 2016-09-28 Add -Q, or --mute, switch to mute notifications about successfully upgrading getssl (1.49) +# 2016-09-30 improved portability to work natively on FreeBSD, Slackware and Mac OS X (1.50) +# 2016-09-30 comment out PRIVATE_KEY_ALG from the domain template Issue #125 (1.51) +# 2016-10-03 check remote certificate for right domain before saving to local (1.52) +# 2016-10-04 allow existing CSR with domain name in subject (1.53) +# 2016-10-05 improved the check for CSR with domain in subject (1.54) +# 2016-10-06 prints update info on what was included in latest updates (1.55) +# 2016-10-06 when using -a flag, ignore folders in working directory which aren't domains (1.56) +# 2016-10-12 allow multiple tokens in DNS challenge (1.57) +# 2016-10-14 added CHECK_ALL_AUTH_DNS option to check all DNS servers, not just one primary server (1.58) +# 2016-10-14 added archive of chain and private key for each cert, and purge old archives (1.59) +# 2016-10-17 updated info comment on failed cert due to rate limits. (1.60) +# 2016-10-17 fix error messages when using 1.0.1e-fips (1.61) +# 2016-10-20 set secure permissions when generating account key (1.62) +# 2016-10-20 set permissions to 700 for getssl script during upgrade (1.63) +# 2016-10-20 add option to revoke a certificate (1.64) +# 2016-10-21 set revocation server default to acme-v01.api.letsencrypt.org (1.65) +# 2016-10-21 bug fix for revocation on different servers. (1.66) +# 2016-10-22 Tidy up archive code for certificates and reduce permissions for security +# 2016-10-22 Add EC signing for secp384r1 and secp521r1 (the latter not yet supported by Let's Encrypt +# 2016-10-22 Add option to create a new private key for every cert (REUSE_PRIVATE_KEY="true" by default) +# 2016-10-22 Combine EC signing, Private key reuse and archive permissions (1.67) +# 2016-10-25 added CHECK_REMOTE_WAIT option ( to pause before final remote check) +# 2016-10-25 Added EC account key support ( prime256v1, secp384r1 ) (1.68) +# 2016-10-25 Ignore DNS_EXTRA_WAIT if all domains already validated (issue #146) (1.69) +# 2016-10-25 Add option for dual ESA / EDSA certs (1.70) +# 2016-10-25 bug fix Issue #141 challenge error 400 (1.71) +# 2016-10-26 check content of key files, not just recreate if missing. +# 2016-10-26 Improvements on portability (1.72) +# 2016-10-26 Date formatting for busybox (1.73) +# 2016-10-27 bug fix - issue #157 not recognising EC keys on some versions of openssl (1.74) +# 2016-10-31 generate EC account keys and tidy code. +# 2016-10-31 fix warning message if cert doesn't exist (1.75) +# 2016-10-31 remove only specified DNS token #161 (1.76) +# 2016-11-03 Reduce long lines, and remove echo from update (1.77) +# 2016-11-05 added TOKEN_USER_ID (to set ownership of token files ) +# 2016-11-05 updated style to work with latest shellcheck (1.78) +# 2016-11-07 style updates +# 2016-11-07 bug fix DOMAIN_PEM_LOCATION starting with ./ #167 +# 2016-11-08 Fix for openssl 1.1.0 #166 (1.79) +# 2016-11-08 Add and comment optional sshuserid for ssh ACL (1.80) +# 2016-11-09 Add SKIP_HTTP_TOKEN_CHECK option (Issue #170) (1.81) +# 2016-11-13 bug fix DOMAIN_KEY_CERT generation (1.82) +# 2016-11-17 add PREVENT_NON_INTERACTIVE_RENEWAL option (1.83) +# 2016-12-03 add HTTP_TOKEN_CHECK_WAIT option (1.84) +# 2016-12-03 bugfix CSR renewal when no SANS and when using MINGW (1.85) +# 2016-12-16 create CSR_SUBJECT variable - Issue #193 +# 2016-12-16 added fullchain to archive (1.86) +# 2016-12-16 updated DOMAIN_PEM_LOCATION when using DUAL_RSA_ECDSA (1.87) +# 2016-12-19 allow user to ignore permission preservation with nfsv3 shares (1.88) +# 2016-12-19 bug fix for CA (1.89) +# 2016-12-19 included IGNORE_DIRECTORY_DOMAIN option (1.90) +# 2016-12-22 allow copying files to multiple locations (1.91) +# 2016-12-22 bug fix for copying tokens to multiple locations (1.92) +# 2016-12-23 tidy code - place default variables in alphabetical order. +# 2016-12-27 update checks to work with openssl in FIPS mode (1.93) +# 2016-12-28 fix leftover tmpfiles in upgrade routine (1.94) +# 2016-12-28 tidied up upgrade tmpfile handling (1.95) +# 2017-01-01 update comments +# 2017-01-01 create stable release 2.0 (2.00) +# 2017-01-02 Added option to limit number of old versions to keep (2.01) +# 2017-01-03 Created check_config function to list all obvious config issues (2.02) +# 2017-01-10 force renew if FORCE_RENEWAL file exists (2.03) +# 2017-01-12 added drill, dig or host as alternatives to nslookup (2.04) +# 2017-01-18 bugfix issue #227 - error deleting csr if doesn't exist +# 2017-01-18 issue #228 check private key and account key are different (2.05) +# 2017-01-21 issue #231 mingw bugfix and typos in debug messages (2.06) +# 2017-01-29 issue #232 use neutral locale for date formatting (2.07) +# 2017-01-30 issue #243 compatibility with bash 3.0 (2.08) +# 2017-01-30 issue #243 additional compatibility with bash 3.0 (2.09) +# 2017-02-18 add OCSP Must-Staple to the domain csr generation (2.10) +# 2018-01-04 updating to use the updated letsencrypt APIv2 +# 2019-09-30 issue #423 Use HTTP 1.1 as workaround atm (2.11) +# 2019-10-02 issue #425 Case insensitive processing of agreement url because of HTTP/2 (2.12) +# 2019-10-07 update DNS checks to allow use of CNAMEs (2.13) +# 2019-11-18 Rebased master onto APIv2 and added Content-Type: application/jose+json (2.14) +# 2019-11-20 #453 and #454 Add User-Agent to all curl requests +# 2019-11-22 #456 Fix shellcheck issues +# 2019-11-23 #459 Fix missing chain.crt +# 2019-12-18 #462 Use POST-as-GET for ACMEv2 endpoints +# 2020-01-07 #464 and #486 "json was blank" (change all curl request to use POST-as-GET) +# 2020-01-08 Error and exit if rate limited, exit if curl returns nothing +# 2020-01-10 Change domain and getssl templates to v2 (2.15) +# 2020-01-17 #473 and #477 Don't use POST-as-GET when sending ready for challenge for ACMEv1 (2.16) +# ---------------------------------------------------------------------------------------- + +PROGNAME=${0##*/} +VERSION="2.16" + +# defaults +ACCOUNT_KEY_LENGTH=4096 +ACCOUNT_KEY_TYPE="rsa" +CA="https://acme-staging-v02.api.letsencrypt.org/directory" +CA_CERT_LOCATION="" +CHALLENGE_CHECK_TYPE="http" +CHECK_ALL_AUTH_DNS="false" +CHECK_REMOTE="true" +CHECK_REMOTE_WAIT=0 +CODE_LOCATION="https://raw.githubusercontent.com/srvrco/getssl/master/getssl" +CSR_SUBJECT="/" +CURL_USERAGENT="${PROGNAME}/${VERSION}" +DEACTIVATE_AUTH="false" +DEFAULT_REVOKE_CA="https://acme-v02.api.letsencrypt.org" +DNS_EXTRA_WAIT="" +DNS_WAIT=10 +DOMAIN_KEY_LENGTH=4096 +DUAL_RSA_ECDSA="false" +GETSSL_IGNORE_CP_PRESERVE="false" +HTTP_TOKEN_CHECK_WAIT=0 +IGNORE_DIRECTORY_DOMAIN="false" +ORIG_UMASK=$(umask) +PREVIOUSLY_VALIDATED="true" +PRIVATE_KEY_ALG="rsa" +PUBLIC_DNS_SERVER="" +RELOAD_CMD="" +RENEW_ALLOW="30" +REUSE_PRIVATE_KEY="true" +SERVER_TYPE="https" +SKIP_HTTP_TOKEN_CHECK="false" +SSLCONF="$(openssl version -d 2>/dev/null| cut -d\" -f2)/openssl.cnf" +OCSP_MUST_STAPLE="false" +TEMP_UPGRADE_FILE="" +TOKEN_USER_ID="" +USE_SINGLE_ACL="false" +VALIDATE_VIA_DNS="" +WORKING_DIR=~/.getssl +_CHECK_ALL=0 +_CREATE_CONFIG=0 +_FORCE_RENEW=0 +_KEEP_VERSIONS="" +_MUTE=0 +_QUIET=0 +_RECREATE_CSR=0 +_REVOKE=0 +_UPGRADE=0 +_UPGRADE_CHECK=1 +_USE_DEBUG=0 +_INFO_COLOR="" +_RESET=$(tput sgr0) +config_errors="false" +LANG=C +API=1 + +# store copy of original command in case of upgrading script and re-running +ORIGCMD="$0 $*" + +# Define all functions (in alphabetical order) + +cert_archive() { # Archive certificate file by copying files to dated archive dir. + debug "creating an archive copy of current new certs" + date_time=$(date +%Y_%m_%d_%H_%M) + mkdir -p "${DOMAIN_DIR}/archive/${date_time}" + umask 077 + cp "$CERT_FILE" "${DOMAIN_DIR}/archive/${date_time}/${DOMAIN}.crt" + cp "$DOMAIN_DIR/${DOMAIN}.csr" "${DOMAIN_DIR}/archive/${date_time}/${DOMAIN}.csr" + cp "$DOMAIN_DIR/${DOMAIN}.key" "${DOMAIN_DIR}/archive/${date_time}/${DOMAIN}.key" + cp "$CA_CERT" "${DOMAIN_DIR}/archive/${date_time}/chain.crt" + cat "$CERT_FILE" "$CA_CERT" > "${DOMAIN_DIR}/archive/${date_time}/fullchain.crt" + if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then + cp "${CERT_FILE%.*}.ec.crt" "${DOMAIN_DIR}/archive/${date_time}/${DOMAIN}.ec.crt" + cp "$DOMAIN_DIR/${DOMAIN}.ec.csr" "${DOMAIN_DIR}/archive/${date_time}/${DOMAIN}.ec.csr" + cp "$DOMAIN_DIR/${DOMAIN}.ec.key" "${DOMAIN_DIR}/archive/${date_time}/${DOMAIN}.ec.key" + cp "${CA_CERT%.*}.ec.crt" "${DOMAIN_DIR}/archive/${date_time}/chain.ec.crt" + cat "${CERT_FILE%.*}.ec.crt" "${CA_CERT%.*}.ec.crt" > "${DOMAIN_DIR}/archive/${date_time}/fullchain.ec.crt" + fi + umask "$ORIG_UMASK" + debug "purging old GetSSL archives" + purge_archive "$DOMAIN_DIR" +} + +check_challenge_completion() { # checks with the ACME server if our challenge is OK + uri=$1 + domain=$2 + keyauthorization=$3 + + debug "sending request to ACME server saying we're ready for challenge" + + # check response from our request to perform challenge + if [[ $API -eq 1 ]]; then + send_signed_request "$uri" "{\"resource\": \"challenge\", \"keyAuthorization\": \"$keyauthorization\"}" + if [[ -n "$code" ]] && [[ ! "$code" == '202' ]] ; then + error_exit "$domain:Challenge error: $code" + fi + else # APIv2 + send_signed_request "$uri" "{}" + if [[ -n "$code" ]] && [[ ! "$code" == '200' ]] ; then + detail=$(echo "$response" | grep "detail" | awk -F\" '{print $4}') + error_exit "$domain:Challenge error: $code:Detail: $detail" + fi + fi + + # loop "forever" to keep checking for a response from the ACME server. + while true ; do + debug "checking if challenge is complete" + if [[ $API -eq 1 ]]; then + if ! get_cr "$uri" ; then + error_exit "$domain:Verify error:$code" + fi + else # APIv2 + send_signed_request "$uri" "" + fi + + status=$(json_get "$response" status) + + # If ACME response is valid, then break out of loop + if [[ "$status" == "valid" ]] ; then + info "Verified $domain" + break; + fi + + # if ACME response is that their check gave an invalid response, error exit + if [[ "$status" == "invalid" ]] ; then + error_exit "$domain:Verify error:$(echo "$response" | grep "detail" | awk -F' "' '{print $3}')" + fi + + # if ACME response is pending ( they haven't completed checks yet) then wait and try again. + if [[ "$status" == "pending" ]] ; then + info "Pending" + else + error_exit "$domain:Verify error:$(echo "$response" | grep "detail")" + fi + debug "sleep 5 secs before testing verify again" + sleep 5 + done + + if [[ "$DEACTIVATE_AUTH" == "true" ]]; then + deactivate_url=$(echo "$responseHeaders" | grep "^Link" | awk -F"[<>]" '{print $2}') + deactivate_url_list="$deactivate_url_list $deactivate_url" + debug "adding url to deactivate list - $deactivate_url" + fi +} + +check_config() { # check the config files for all obvious errors + debug "checking config" + + # check keys + case "$ACCOUNT_KEY_TYPE" in + rsa|prime256v1|secp384r1|secp521r1) + debug "checked ACCOUNT_KEY_TYPE " ;; + *) + info "${DOMAIN}: invalid ACCOUNT_KEY_TYPE - $ACCOUNT_KEY_TYPE" + config_errors=true ;; + esac + if [[ "$ACCOUNT_KEY" == "$DOMAIN_DIR/${DOMAIN}.key" ]]; then + info "${DOMAIN}: ACCOUNT_KEY and domain key ( $DOMAIN_DIR/${DOMAIN}.key ) must be different" + config_errors=true + fi + case "$PRIVATE_KEY_ALG" in + rsa|prime256v1|secp384r1|secp521r1) + debug "checked PRIVATE_KEY_ALG " ;; + *) + info "${DOMAIN}: invalid PRIVATE_KEY_ALG - $PRIVATE_KEY_ALG" + config_errors=true ;; + esac + if [[ "$DUAL_RSA_ECDSA" == "true" ]] && [[ "$PRIVATE_KEY_ALG" == "rsa" ]]; then + info "${DOMAIN}: PRIVATE_KEY_ALG not set to an EC type and DUAL_RSA_ECDSA=\"true\"" + config_errors=true + fi + + # get all domains + if [[ "$IGNORE_DIRECTORY_DOMAIN" == "true" ]]; then + alldomains=${SANS//,/ } + else + alldomains=$(echo "$DOMAIN,$SANS" | sed "s/,/ /g") + fi + if [[ -z "$alldomains" ]]; then + info "${DOMAIN}: no domains specified" + config_errors=true + fi + + if [[ $VALIDATE_VIA_DNS == "true" ]]; then # using dns-01 challenge + if [[ -z "$DNS_ADD_COMMAND" ]]; then + info "${DOMAIN}: DNS_ADD_COMMAND not defined (whilst VALIDATE_VIA_DNS=\"true\")" + config_errors=true + fi + if [[ -z "$DNS_DEL_COMMAND" ]]; then + info "${DOMAIN}: DNS_DEL_COMMAND not defined (whilst VALIDATE_VIA_DNS=\"true\")" + config_errors=true + fi + fi + + dn=0 + tmplist=$(mktemp 2>/dev/null || mktemp -t getssl) + for d in $alldomains; do # loop over domains (dn is domain number) + debug "checking domain $d" + if [[ "$(grep "^${d}$" "$tmplist")" = "$d" ]]; then + info "${DOMAIN}: $d appears to be duplicated in domain, SAN list" + config_errors=true + else + echo "$d" >> "$tmplist" + fi + + if [[ "$USE_SINGLE_ACL" == "true" ]]; then + DOMAIN_ACL="${ACL[0]}" + else + DOMAIN_ACL="${ACL[$dn]}" + fi + + if [[ $VALIDATE_VIA_DNS != "true" ]]; then # using http-01 challenge + if [[ -z "${DOMAIN_ACL}" ]]; then + info "${DOMAIN}: ACL location not specified for domain $d in $DOMAIN_DIR/getssl.cfg" + config_errors=true + fi + # check domain exist + if [[ "$DNS_CHECK_FUNC" == "drill" ]] || [[ "$DNS_CHECK_FUNC" == "dig" ]]; then + if [[ "$($DNS_CHECK_FUNC "${d}" |grep -c "${d}")" -ge 1 ]]; then + debug "found IP for ${d}" + else + info "${DOMAIN}: DNS lookup failed for ${d}" + config_errors=true + fi + elif [[ "$DNS_CHECK_FUNC" == "host" ]]; then + if [[ "$($DNS_CHECK_FUNC "${d}" |grep -c "^${d}")" -ge 1 ]]; then + debug "found IP for ${d}" + else + info "${DOMAIN}: DNS lookup failed for ${d}" + config_errors=true + fi + elif [[ "$(nslookup -query=AAAA "${d}"|grep -c "^${d}.*has AAAA address")" -ge 1 ]]; then + debug "found IPv6 record for ${d}" + elif [[ "$(nslookup "${d}"| grep -c ^Name)" -ge 1 ]]; then + debug "found IPv4 record for ${d}" + else + info "${DOMAIN}: DNS lookup failed for $d" + config_errors=true + fi + fi # end using http-01 challenge + ((dn++)) + done + + # tidy up + rm -f "$tmplist" + + if [[ "$config_errors" == "true" ]]; then + error_exit "${DOMAIN}: exiting due to config errors" + fi + debug "${DOMAIN}: check_config completed - all OK" +} + +check_getssl_upgrade() { # check if a more recent version of code is available available + TEMP_UPGRADE_FILE="$(mktemp 2>/dev/null || mktemp -t getssl)" + curl --user-agent "$CURL_USERAGENT" --silent "$CODE_LOCATION" --output "$TEMP_UPGRADE_FILE" + errcode=$? + if [[ $errcode -eq 60 ]]; then + error_exit "curl needs updating, your version does not support SNI (multiple SSL domains on a single IP)" + elif [[ $errcode -gt 0 ]]; then + error_exit "curl error : $errcode" + fi + latestversion=$(awk -F '"' '$1 == "VERSION=" {print $2}' "$TEMP_UPGRADE_FILE") + latestvdec=$(echo "$latestversion"| tr -d '.') + localvdec=$(echo "$VERSION"| tr -d '.' ) + debug "current code is version ${VERSION}" + debug "Most recent version is ${latestversion}" + # use a default of 0 for cases where the latest code has not been obtained. + if [[ "${latestvdec:-0}" -gt "$localvdec" ]]; then + if [[ ${_UPGRADE} -eq 1 ]]; then + install "$0" "${0}.v${VERSION}" + install -m 700 "$TEMP_UPGRADE_FILE" "$0" + if [[ ${_MUTE} -eq 0 ]]; then + echo "Updated getssl from v${VERSION} to v${latestversion}" + echo "these update notification can be turned off using the -Q option" + echo "" + echo "Updates are;" + awk "/\(${VERSION}\)$/ {s=1} s; /\(${latestversion}\)$/ {s=0}" "$TEMP_UPGRADE_FILE" | awk '{if(NR>1)print}' + echo "" + fi + if [[ -n "$_KEEP_VERSIONS" ]] && [[ "$_KEEP_VERSIONS" =~ ^[0-9]+$ ]]; then + # Obtain all locally stored old versions in getssl_versions + declare -a getssl_versions + shopt -s nullglob + for getssl_version in "$0".v*; do + getssl_versions[${#getssl_versions[@]}]="$getssl_version" + done + shopt -u nullglob + # Explicitly sort the getssl_versions array to make sure + shopt -s -o noglob + # shellcheck disable=SC2207 + IFS=$'\n' getssl_versions=($(sort <<< "${getssl_versions[*]}")) + shopt -u -o noglob + # Remove entries until given number of old versions to keep is reached + while [[ ${#getssl_versions[@]} -gt $_KEEP_VERSIONS ]]; do + debug "removing old version ${getssl_versions[0]}" + rm "${getssl_versions[0]}" + getssl_versions=("${getssl_versions[@]:1}") + done + fi + eval "$ORIGCMD" + graceful_exit + else + info "" + info "A more recent version (v${latestversion}) of getssl is available, please update" + info "the easiest way is to use the -u or --upgrade flag" + info "" + fi + fi +} + +clean_up() { # Perform pre-exit housekeeping + umask "$ORIG_UMASK" + if [[ $VALIDATE_VIA_DNS == "true" ]]; then + # Tidy up DNS entries if things failed part way though. + shopt -s nullglob + for dnsfile in "$TEMP_DIR"/dns_verify/*; do + # shellcheck source=/dev/null + . "$dnsfile" + debug "attempting to clean up DNS entry for $d" + eval "$DNS_DEL_COMMAND" "$d" "$auth_key" + done + shopt -u nullglob + fi + if [[ -n "$DOMAIN_DIR" ]]; then + rm -rf "${TEMP_DIR:?}" + fi + if [[ -n "$TEMP_UPGRADE_FILE" ]] && [[ -f "$TEMP_UPGRADE_FILE" ]]; then + rm -f "$TEMP_UPGRADE_FILE" + fi +} + +copy_file_to_location() { # copies a file, using scp, sftp or ftp if required. + cert=$1 # descriptive name, just used for display + from=$2 # current file location + to=$3 # location to move file to. + IFS=\; read -r -a copy_locations <<<"$3" + for to in "${copy_locations[@]}"; do + info "copying $cert to $to" + if [[ "${to:0:4}" == "ssh:" ]] ; then + debug "using scp scp -q $from ${to:4}" + if ! scp -q "$from" "${to:4}" >/dev/null 2>&1 ; then + error_exit "problem copying file to the server using scp. + scp $from ${to:4}" + fi + debug "userid $TOKEN_USER_ID" + if [[ "$cert" == "challenge token" ]] && [[ -n "$TOKEN_USER_ID" ]]; then + servername=$(echo "$to" | awk -F":" '{print $2}') + tofile=$(echo "$to" | awk -F":" '{print $3}') + debug "servername $servername" + debug "file $tofile" + # shellcheck disable=SC2029 + # shellcheck disable=SC2086 + ssh $SSH_OPTS "$servername" "chown $TOKEN_USER_ID $tofile" + fi + elif [[ "${to:0:4}" == "ftp:" ]] ; then + if [[ "$cert" != "challenge token" ]] ; then + error_exit "ftp is not a secure method for copying certificates or keys" + fi + debug "using ftp to copy the file from $from" + ftpuser=$(echo "$to"| awk -F: '{print $2}') + ftppass=$(echo "$to"| awk -F: '{print $3}') + ftphost=$(echo "$to"| awk -F: '{print $4}') + ftplocn=$(echo "$to"| awk -F: '{print $5}') + ftpdirn=$(dirname "$ftplocn") + ftpfile=$(basename "$ftplocn") + fromdir=$(dirname "$from") + fromfile=$(basename "$from") + debug "ftp user=$ftpuser - pass=$ftppass - host=$ftphost dir=$ftpdirn file=$ftpfile" + debug "from dir=$fromdir file=$fromfile" + ftp -n <<- _EOF + open $ftphost + user $ftpuser $ftppass + cd $ftpdirn + lcd $fromdir + put $fromfile + _EOF + elif [[ "${to:0:5}" == "sftp:" ]] ; then + debug "using sftp to copy the file from $from" + ftpuser=$(echo "$to"| awk -F: '{print $2}') + ftppass=$(echo "$to"| awk -F: '{print $3}') + ftphost=$(echo "$to"| awk -F: '{print $4}') + ftplocn=$(echo "$to"| awk -F: '{print $5}') + ftpdirn=$(dirname "$ftplocn") + ftpfile=$(basename "$ftplocn") + fromdir=$(dirname "$from") + fromfile=$(basename "$from") + debug "sftp user=$ftpuser - pass=$ftppass - host=$ftphost dir=$ftpdirn file=$ftpfile" + debug "from dir=$fromdir file=$fromfile" + sshpass -p "$ftppass" sftp "$ftpuser@$ftphost" <<- _EOF + cd $ftpdirn + lcd $fromdir + put $fromfile + _EOF + else + if ! mkdir -p "$(dirname "$to")" ; then + error_exit "cannot create ACL directory $(basename "$to")" + fi + if [[ "$GETSSL_IGNORE_CP_PRESERVE" == "true" ]]; then + if ! cp "$from" "$to" ; then + error_exit "cannot copy $from to $to" + fi + else + if ! cp -p "$from" "$to" ; then + error_exit "cannot copy $from to $to" + fi + fi + if [[ "$cert" == "challenge token" ]] && [[ -n "$TOKEN_USER_ID" ]]; then + chown "$TOKEN_USER_ID" "$to" + fi + fi + debug "copied $from to $to" + done +} + +create_csr() { # create a csr using a given key (if it doesn't already exist) + csr_file=$1 + csr_key=$2 + # check if domain csr exists - if not then create it + if [[ -s "$csr_file" ]]; then + debug "domain csr exists at - $csr_file" + # check all domains in config are in csr + if [[ "$IGNORE_DIRECTORY_DOMAIN" == "true" ]]; then + alldomains=$(echo "$SANS" | sed -e 's/ //g; s/,$//; y/,/\n/' | sort -u) + else + alldomains=$(echo "$DOMAIN,$SANS" | sed -e 's/ //g; s/,$//; y/,/\n/' | sort -u) + fi + domains_in_csr=$(openssl req -text -noout -in "$csr_file" \ + | sed -n -e 's/^ *Subject: .* CN=\([A-Za-z0-9.-]*\).*$/\1/p; /^ *DNS:.../ { s/ *DNS://g; y/,/\n/; p; }' \ + | sort -u) + for d in $alldomains; do + if [[ "$(echo "${domains_in_csr}"| grep "^${d}$")" != "${d}" ]]; then + info "existing csr at $csr_file does not contain ${d} - re-create-csr"\ + ".... $(echo "${domains_in_csr}"| grep "^${d}$")" + _RECREATE_CSR=1 + fi + done + # check all domains in csr are in config + if [[ "$alldomains" != "$domains_in_csr" ]]; then + info "existing csr at $csr_file does not have the same domains as the config - re-create-csr" + _RECREATE_CSR=1 + fi + fi + # end of ... check if domain csr exists - if not then create it + + # if CSR does not exist, or flag set to recreate, then create csr + if [[ ! -s "$csr_file" ]] || [[ "$_RECREATE_CSR" == "1" ]]; then + info "creating domain csr - $csr_file" + # create a temporary config file, for portability. + tmp_conf=$(mktemp 2>/dev/null || mktemp -t getssl) + cat "$SSLCONF" > "$tmp_conf" + printf "[SAN]\n%s" "$SANLIST" >> "$tmp_conf" + # add OCSP Must-Staple to the domain csr + # if openssl version >= 1.1.0 one can also use "tlsfeature = status_request" + if [[ "$OCSP_MUST_STAPLE" == "true" ]]; then + printf "\n1.3.6.1.5.5.7.1.24 = DER:30:03:02:01:05" >> "$tmp_conf" + fi + openssl req -new -sha256 -key "$csr_key" -subj "$CSR_SUBJECT" -reqexts SAN -config "$tmp_conf" > "$csr_file" + rm -f "$tmp_conf" + fi +} + +create_key() { # create a domain key (if it doesn't already exist) + key_type=$1 # domain key type + key_loc=$2 # domain key location + key_len=$3 # domain key length - for rsa keys. + # check if key exists, if not then create it. + if [[ -s "$key_loc" ]]; then + debug "domain key exists at $key_loc - skipping generation" + # ideally need to check validity of domain key + else + umask 077 + info "creating key - $key_loc" + case "$key_type" in + rsa) + openssl genrsa "$key_len" > "$key_loc";; + prime256v1|secp384r1|secp521r1) + openssl ecparam -genkey -name "$key_type" > "$key_loc";; + *) + error_exit "unknown private key algorithm type $key_loc";; + esac + umask "$ORIG_UMASK" + # remove csr on generation of new domain key + if [[ -e "${key_loc%.*}.csr" ]]; then + rm -f "${key_loc%.*}.csr" + fi + fi +} + +create_order() { + dstring="[" + for d in $alldomains; do + dstring="${dstring}{\"type\":\"dns\",\"value\":\"$d\"}," + done + dstring="${dstring::${#dstring}-1}]" + # request NewOrder currently seems to ignore the dates .... + # dstring="${dstring},\"notBefore\": \"$(date -d "-1 hour" --utc +%FT%TZ)\"" + # dstring="${dstring},\"notAfter\": \"$(date -d "2 days" --utc +%FT%TZ)\"" + request="{\"identifiers\": $dstring}" + send_signed_request "$URL_newOrder" "$request" + OrderLink=$(echo "$responseHeaders" | grep -i location | awk '{print $2}'| tr -d '\r\n ') + debug "Order link $OrderLink" + FinalizeLink=$(json_get "$response" "finalize") + dn=0 + for d in $alldomains; do + # get authorizations link + AuthLink[$dn]=$(json_get "$response" "identifiers" "value" "$d" "authorizations" "x") + debug "authorizations link for $d - ${AuthLink[$dn]}" + ((dn++)) + done +} + +date_epoc() { # convert the date into epoch time + if [[ "$os" == "bsd" ]]; then + date -j -f "%b %d %T %Y %Z" "$1" +%s + elif [[ "$os" == "mac" ]]; then + date -j -f "%b %d %T %Y %Z" "$1" +%s + elif [[ "$os" == "busybox" ]]; then + de_ld=$(echo "$1" | awk '{print $1 $2 $3 $4}') + date -D "%b %d %T %Y" -d "$de_ld" +%s + else + date -d "$1" +%s + fi + +} + +date_fmt() { # format date from epoc time to YYYY-MM-DD + if [[ "$os" == "bsd" ]]; then #uses older style date function. + date -j -f "%s" "$1" +%F + elif [[ "$os" == "mac" ]]; then # macOS uses older BSD style date. + date -j -f "%s" "$1" +%F + else + date -d "@$1" +%F + fi +} + +date_renew() { # calculates the renewal time in epoch + date_now_s=$( date +%s ) + echo "$((date_now_s + RENEW_ALLOW*24*60*60))" +} + +debug() { # write out debug info if the debug flag has been set + if [[ ${_USE_DEBUG} -eq 1 ]]; then + echo " " + echo "$@" + fi +} + +error_exit() { # give error message on error exit + echo -e "${PROGNAME}: ${1:-"Unknown Error"}" >&2 + clean_up + exit 1 +} + +fulfill_challenges() { +dn=0 +for d in $alldomains; do + # $d is domain in current loop, which is number $dn for ACL + info "Verifying $d" + if [[ "$USE_SINGLE_ACL" == "true" ]]; then + DOMAIN_ACL="${ACL[0]}" + else + DOMAIN_ACL="${ACL[$dn]}" + fi + + # request a challenge token from ACME server + debug "Requesting challenge tokens" + if [[ $API -eq 1 ]]; then + request="{\"resource\":\"new-authz\",\"identifier\":{\"type\":\"dns\",\"value\":\"$d\"}}" + send_signed_request "$URL_new_authz" "$request" + debug "completed send_signed_request" + + # check if we got a valid response and token, if not then error exit + if [[ -n "$code" ]] && [[ ! "$code" == '201' ]] ; then + error_exit "new-authz error: $response" + fi + else + send_signed_request "${AuthLink[$dn]}" "" + fi + + if [[ $response_status == "valid" ]]; then + info "$d is already validated" + if [[ "$DEACTIVATE_AUTH" == "true" ]]; then + deactivate_url="$(echo "$responseHeaders" | awk ' $1 ~ "^Location" {print $2}' | tr -d "\r")" + deactivate_url_list+=" $deactivate_url " + debug "url added to deactivate list ${deactivate_url}" + debug "deactivate list is now $deactivate_url_list" + fi + # increment domain-counter + ((dn++)) + else + PREVIOUSLY_VALIDATED="false" + if [[ $VALIDATE_VIA_DNS == "true" ]]; then # set up the correct DNS token for verification + if [[ $API -eq 1 ]]; then + # get the dns component of the ACME response + # get the token from the dns component + token=$(json_get "$response" "token" "dns-01") + # get the uri from the dns component + uri=$(json_get "$response" "uri" "dns-01") + debug uri "$uri" + else # APIv2 + debug "authlink response = $response" + # get the token from the http-01 component + token=$(json_get "$response" "challenges" "type" "dns-01" "token") + # get the uri from the http component + uri=$(json_get "$response" "challenges" "type" "dns-01" "url") + debug uri "$uri" + fi + + keyauthorization="$token.$thumbprint" + debug keyauthorization "$keyauthorization" + + #create signed authorization key from token. + auth_key=$(printf '%s' "$keyauthorization" | openssl dgst -sha256 -binary \ + | openssl base64 -e \ + | tr -d '\n\r' \ + | sed -e 's:=*$::g' -e 'y:+/:-_:') + debug auth_key "$auth_key" + + debug "adding dns via command: $DNS_ADD_COMMAND $d $auth_key" + if ! eval "$DNS_ADD_COMMAND" "$d" "$auth_key" ; then + error_exit "DNS_ADD_COMMAND failed for domain $d" + fi + + # find a primary / authoritative DNS server for the domain + if [[ -z "$AUTH_DNS_SERVER" ]]; then + get_auth_dns "$d" + else + primary_ns="$AUTH_DNS_SERVER" + fi + debug primary_ns "$primary_ns" + + # make a directory to hold pending dns-challenges + if [[ ! -d "$TEMP_DIR/dns_verify" ]]; then + mkdir "$TEMP_DIR/dns_verify" + fi + + # generate a file with the current variables for the dns-challenge + cat > "$TEMP_DIR/dns_verify/$d" <<- _EOF_ + token="${token}" + uri="${uri}" + keyauthorization="${keyauthorization}" + d="${d}" + primary_ns="${primary_ns}" + auth_key="${auth_key}" + _EOF_ + + else # set up the correct http token for verification + if [[ $API -eq 1 ]]; then + # get the token from the http component + token=$(json_get "$response" "token" "http-01") + # get the uri from the http component + uri=$(json_get "$response" "uri" "http-01") + debug uri "$uri" + else # APIv2 + send_signed_request "${AuthLink[$dn]}" "" + debug "authlink response = $response" + # get the token from the http-01 component + token=$(json_get "$response" "challenges" "type" "http-01" "token") + # get the uri from the http component + uri=$(json_get "$response" "challenges" "type" "http-01" "url" | head -n1) + debug uri "$uri" + fi + + #create signed authorization key from token. + keyauthorization="$token.$thumbprint" + + # save variable into temporary file + echo -n "$keyauthorization" > "$TEMP_DIR/$token" + chmod 644 "$TEMP_DIR/$token" + + # copy to token to acme challenge location + umask 0022 + IFS=\; read -r -a token_locations <<<"$DOMAIN_ACL" + for t_loc in "${token_locations[@]}"; do + debug "copying file from $TEMP_DIR/$token to ${t_loc}" + copy_file_to_location "challenge token" \ + "$TEMP_DIR/$token" \ + "${t_loc}/$token" + done + umask "$ORIG_UMASK" + + wellknown_url="${CHALLENGE_CHECK_TYPE}://${d}/.well-known/acme-challenge/$token" + debug wellknown_url "$wellknown_url" + + if [[ "$SKIP_HTTP_TOKEN_CHECK" == "true" ]]; then + info "SKIP_HTTP_TOKEN_CHECK=true so not checking that token is working correctly" + else + sleep "$HTTP_TOKEN_CHECK_WAIT" + # check that we can reach the challenge ourselves, if not, then error + if [[ ! "$(curl --user-agent "$CURL_USERAGENT" -k --silent --location "$wellknown_url")" == "$keyauthorization" ]]; then + error_exit "for some reason could not reach $wellknown_url - please check it manually" + fi + fi + + check_challenge_completion "$uri" "$d" "$keyauthorization" + + debug "remove token from ${DOMAIN_ACL}" + IFS=\; read -r -a token_locations <<<"$DOMAIN_ACL" + for t_loc in "${token_locations[@]}"; do + if [[ "${t_loc:0:4}" == "ssh:" ]] ; then + sshhost=$(echo "${t_loc}"| awk -F: '{print $2}') + command="rm -f ${t_loc:(( ${#sshhost} + 5))}/${token:?}" + debug "running following command to remove token" + debug "ssh $SSH_OPTS $sshhost ${command}" + # shellcheck disable=SC2029 + # shellcheck disable=SC2086 + ssh $SSH_OPTS "$sshhost" "${command}" 1>/dev/null 2>&1 + rm -f "${TEMP_DIR:?}/${token:?}" + elif [[ "${t_loc:0:4}" == "ftp:" ]] ; then + debug "using ftp to remove token file" + ftpuser=$(echo "${t_loc}"| awk -F: '{print $2}') + ftppass=$(echo "${t_loc}"| awk -F: '{print $3}') + ftphost=$(echo "${t_loc}"| awk -F: '{print $4}') + ftplocn=$(echo "${t_loc}"| awk -F: '{print $5}') + debug "ftp user=$ftpuser - pass=$ftppass - host=$ftphost location=$ftplocn" + ftp -n <<- EOF + open $ftphost + user $ftpuser $ftppass + cd $ftplocn + delete ${token:?} + EOF + else + rm -f "${t_loc:?}/${token:?}" + fi + done + fi + # increment domain-counter + ((dn++)) + fi +done # end of ... loop through domains for cert ( from SANS list) +# perform validation if via DNS challenge +if [[ $VALIDATE_VIA_DNS == "true" ]]; then + # loop through dns-variable files to check if dns has been changed + for dnsfile in "$TEMP_DIR"/dns_verify/*; do + if [[ -e "$dnsfile" ]]; then + debug "loading DNSfile: $dnsfile" + # shellcheck source=/dev/null + . "$dnsfile" + + # check for token at public dns server, waiting for a valid response. + for ns in $primary_ns; do + debug "checking dns at $ns" + ntries=0 + check_dns="fail" + while [[ "$check_dns" == "fail" ]]; do + if [[ "$os" == "cygwin" ]]; then + check_result=$(nslookup -type=txt "_acme-challenge.${d}" "${ns}" \ + | grep ^_acme -A2\ + | grep '"'|awk -F'"' '{ print $2}') + elif [[ "$DNS_CHECK_FUNC" == "drill" ]] || [[ "$DNS_CHECK_FUNC" == "dig" ]]; then + check_result=$($DNS_CHECK_FUNC TXT "_acme-challenge.${d}" "@${ns}" \ + | grep '300 IN TXT'|awk -F'"' '{ print $2}') + elif [[ "$DNS_CHECK_FUNC" == "host" ]]; then + check_result=$($DNS_CHECK_FUNC -t TXT "_acme-challenge.${d}" "${ns}" \ + | grep 'descriptive text'|awk -F'"' '{ print $2}') + else + check_result=$(nslookup -type=txt "_acme-challenge.${d}" "${ns}" \ + | grep 'text ='|awk -F'"' '{ print $2}') + fi + debug "expecting $auth_key" + debug "${ns} gave ... $check_result" + + if [[ "$check_result" == *"$auth_key"* ]]; then + check_dns="success" + else + if [[ $ntries -lt 100 ]]; then + ntries=$(( ntries + 1 )) + info "checking DNS at ${ns} for ${d}. Attempt $ntries/100 gave wrong result, "\ + "waiting $DNS_WAIT secs before checking again" + sleep $DNS_WAIT + else + debug "dns check failed - removing existing value" + error_exit "checking _acme-challenge.${d} gave $check_result not $auth_key" + fi + fi + done + done + fi + done + + if [[ "$DNS_EXTRA_WAIT" -gt 0 && "$PREVIOUSLY_VALIDATED" != "true" ]]; then + info "sleeping $DNS_EXTRA_WAIT seconds before asking the ACME-server to check the dns" + sleep "$DNS_EXTRA_WAIT" + fi + + # loop through dns-variable files to let the ACME server check the challenges + for dnsfile in "$TEMP_DIR"/dns_verify/*; do + if [[ -e "$dnsfile" ]]; then + debug "loading DNSfile: $dnsfile" + # shellcheck source=/dev/null + . "$dnsfile" + + check_challenge_completion "$uri" "$d" "$keyauthorization" + + debug "remove DNS entry" + eval "$DNS_DEL_COMMAND" "$d" "$auth_key" + # remove $dnsfile after each loop. + rm -f "$dnsfile" + fi + done +fi +# end of ... perform validation if via DNS challenge +#end of varify each domain. +} + +get_auth_dns() { # get the authoritative dns server for a domain (sets primary_ns ) + gad_d="$1" # domain name + gad_s="$PUBLIC_DNS_SERVER" # start with PUBLIC_DNS_SERVER + + if [[ "$os" == "cygwin" ]]; then + all_auth_dns_servers=$(nslookup -type=soa "${d}" ${PUBLIC_DNS_SERVER} 2>/dev/null \ + | grep "primary name server" \ + | awk '{print $NF}') + if [[ -z "$all_auth_dns_servers" ]]; then + error_exit "couldn't find primary DNS server - please set AUTH_DNS_SERVER in config" + fi + primary_ns="$all_auth_dns_servers" + return + fi + + if [[ "$DNS_CHECK_FUNC" == "drill" ]] || [[ "$DNS_CHECK_FUNC" == "dig" ]]; then + if [[ -z "$gad_s" ]]; then #checking for CNAMEs + res=$($DNS_CHECK_FUNC CNAME "$gad_d"| grep "^$gad_d") + else + res=$($DNS_CHECK_FUNC CNAME "$gad_d" "@$gad_s"| grep "^$gad_d") + fi + if [[ -n "$res" ]]; then # domain is a CNAME so get main domain + gad_d=$(echo "$res"| awk '{print $5}' |sed 's/\.$//g') + fi + if [[ -z "$gad_s" ]]; then #checking for CNAMEs + res=$($DNS_CHECK_FUNC NS "$gad_d"| grep "^$gad_d") + else + res=$($DNS_CHECK_FUNC NS "$gad_d" "@$gad_s"| grep "^$gad_d") + fi + if [[ -z "$res" ]]; then + error_exit "couldn't find primary DNS server - please set AUTH_DNS_SERVER in config" + else + all_auth_dns_servers=$(echo "$res" | awk '$4 ~ "NS" {print $5}' | sed 's/\.$//g'|tr '\n' ' ') + fi + if [[ $CHECK_ALL_AUTH_DNS == "true" ]]; then + primary_ns="$all_auth_dns_servers" + else + primary_ns=$(echo "$all_auth_dns_servers" | awk '{print $1}') + fi + return + fi + + if [[ "$DNS_CHECK_FUNC" == "host" ]]; then + if [[ -z "$gad_s" ]]; then + res=$($DNS_CHECK_FUNC -t NS "$gad_d"| grep "name server") + else + res=$($DNS_CHECK_FUNC -t NS "$gad_d" "$gad_s"| grep "name server") + fi + if [[ -z "$res" ]]; then + error_exit "couldn't find primary DNS server - please set AUTH_DNS_SERVER in config" + else + all_auth_dns_servers=$(echo "$res" | awk '{print $4}' | sed 's/\.$//g'|tr '\n' ' ') + fi + if [[ $CHECK_ALL_AUTH_DNS == "true" ]]; then + primary_ns="$all_auth_dns_servers" + else + primary_ns=$(echo "$all_auth_dns_servers" | awk '{print $1}') + fi + return + fi + + res=$(nslookup -debug=1 -type=soa -type=ns "$gad_d" ${gad_s}) + + if [[ "$(echo "$res" | grep -c "Non-authoritative")" -gt 0 ]]; then + # this is a Non-authoritative server, need to check for an authoritative one. + gad_s=$(echo "$res" | awk '$2 ~ "nameserver" {print $4; exit }' |sed 's/\.$//g') + if [[ "$(echo "$res" | grep -c "an't find")" -gt 0 ]]; then + # if domain name doesn't exist, then find auth servers for next level up + gad_s=$(echo "$res" | awk '$1 ~ "origin" {print $3; exit }') + gad_d=$(echo "$res" | awk '$1 ~ "->" {print $2; exit}') + fi + fi + + if [[ -z "$gad_s" ]]; then + res=$(nslookup -debug=1 -type=soa -type=ns "$gad_d") + else + res=$(nslookup -debug=1 -type=soa -type=ns "$gad_d" "${gad_s}") + fi + + if [[ "$(echo "$res" | grep -c "canonical name")" -gt 0 ]]; then + gad_d=$(echo "$res" | awk ' $2 ~ "canonical" {print $5; exit }' |sed 's/\.$//g') + elif [[ "$(echo "$res" | grep -c "an't find")" -gt 0 ]]; then + gad_s=$(echo "$res" | awk ' $1 ~ "origin" {print $3; exit }') + gad_d=$(echo "$res"| awk '$1 ~ "->" {print $2; exit}') + fi + + all_auth_dns_servers=$(nslookup -type=soa -type=ns "$gad_d" "$gad_s" \ + | awk ' $2 ~ "nameserver" {print $4}' \ + | sed 's/\.$//g'| tr '\n' ' ') + if [[ $CHECK_ALL_AUTH_DNS == "true" ]]; then + primary_ns="$all_auth_dns_servers" + else + primary_ns=$(echo "$all_auth_dns_servers" | awk '{print $1}') + fi +} + +get_certificate() { # get certificate for csr, if all domains validated. + gc_csr=$1 # the csr file + gc_certfile=$2 # The filename for the certificate + gc_cafile=$3 # The filename for the CA certificate + + der=$(openssl req -in "$gc_csr" -outform DER | urlbase64) + if [[ $API -eq 1 ]]; then + send_signed_request "$URL_new_cert" "{\"resource\": \"new-cert\", \"csr\": \"$der\"}" "needbase64" + # convert certificate information into correct format and save to file. + CertData=$(awk ' $1 ~ "^Location" {print $2}' "$CURL_HEADER" |tr -d '\r') + if [[ "$CertData" ]] ; then + echo -----BEGIN CERTIFICATE----- > "$gc_certfile" + curl --user-agent "$CURL_USERAGENT" --silent "$CertData" | openssl base64 -e >> "$gc_certfile" + echo -----END CERTIFICATE----- >> "$gc_certfile" + info "Certificate saved in $CERT_FILE" + fi + + # If certificate wasn't a valid certificate, error exit. + if [[ -z "$CertData" ]] ; then + response2=$(echo "$response" | fold -w64 |openssl base64 -d) + debug "response was $response" + error_exit "Sign failed: $(echo "$response2" | grep "detail")" + fi + + # get a copy of the CA certificate. + IssuerData=$(grep -i '^Link' "$CURL_HEADER" \ + | cut -d " " -f 2\ + | cut -d ';' -f 1 \ + | sed 's///g') + if [[ "$IssuerData" ]] ; then + echo -----BEGIN CERTIFICATE----- > "$gc_cafile" + curl --user-agent "$CURL_USERAGENT" --silent "$IssuerData" | openssl base64 -e >> "$gc_cafile" + echo -----END CERTIFICATE----- >> "$gc_cafile" + info "The intermediate CA cert is in $gc_cafile" + fi + else # APIv2 + info "Requesting Finalize Link" + send_signed_request "$FinalizeLink" "{\"csr\": \"$der\"}" "needbase64" + info Requesting Order Link + debug "order link was $OrderLink" + send_signed_request "$OrderLink" "" + # if ACME response is processing (still creating certificates) then wait and try again. + while [[ "$response_status" == "processing" ]]; do + info "ACME server still Processing certificates" + sleep 5 + send_signed_request "$OrderLink" "" + done + info "Requesting certificate" + CertData=$(json_get "$response" "certificate") + send_signed_request "$CertData" "" "" "$FULL_CHAIN" + info "Full certificate saved in $FULL_CHAIN" + awk -v CERT_FILE="$gc_certfile" -v CA_CERT="$gc_cafile" 'BEGIN {outfile=CERT_FILE} split_after==1 {outfile=CA_CERT;split_after=0} /-----END CERTIFICATE-----/ {split_after=1} {print > outfile}' "$FULL_CHAIN" + info "Certificate saved in $gc_certfile" + fi +} + +get_cr() { # get curl response + url="$1" + debug url "$url" + response=$(curl --user-agent "$CURL_USERAGENT" --silent "$url") + ret=$? + debug response "$response" + code=$(json_get "$response" status) + debug code "$code" + debug "get_cr return code $ret" + return $ret +} + +get_os() { # function to get the current Operating System + uname_res=$(uname -s) + if [[ $(date -h 2>&1 | grep -ic busybox) -gt 0 ]]; then + os="busybox" + elif [[ ${uname_res} == "Linux" ]]; then + os="linux" + elif [[ ${uname_res} == "FreeBSD" ]]; then + os="bsd" + elif [[ ${uname_res} == "Darwin" ]]; then + os="mac" + elif [[ ${uname_res:0:6} == "CYGWIN" ]]; then + os="cygwin" + elif [[ ${uname_res:0:5} == "MINGW" ]]; then + os="mingw" + else + os="unknown" + fi + debug "detected os type = $os" + if [[ -f /etc/issue ]]; then + debug "Running $(cat /etc/issue)" + fi +} + +get_signing_params() { # get signing parameters from key + skey=$1 + if openssl rsa -in "${skey}" -noout 2>/dev/null ; then # RSA key + pub_exp64=$(openssl rsa -in "${skey}" -noout -text \ + | grep publicExponent \ + | grep -oE "0x[a-f0-9]+" \ + | cut -d'x' -f2 \ + | hex2bin \ + | urlbase64) + pub_mod64=$(openssl rsa -in "${skey}" -noout -modulus \ + | cut -d'=' -f2 \ + | hex2bin \ + | urlbase64) + + jwk='{"e":"'"${pub_exp64}"'","kty":"RSA","n":"'"${pub_mod64}"'"}' + jwkalg="RS256" + signalg="sha256" + elif openssl ec -in "${skey}" -noout 2>/dev/null ; then # Elliptic curve key. + crv="$(openssl ec -in "$skey" -noout -text 2>/dev/null | awk '$2 ~ "CURVE:" {print $3}')" + if [[ -z "$crv" ]]; then + gsp_keytype="$(openssl ec -in "$skey" -noout -text 2>/dev/null \ + | grep "^ASN1 OID:" \ + | awk '{print $3}')" + case "$gsp_keytype" in + prime256v1) crv="P-256" ;; + secp384r1) crv="P-384" ;; + secp521r1) crv="P-521" ;; + *) error_exit "invalid curve algorithm type $gsp_keytype";; + esac + fi + case "$crv" in + P-256) jwkalg="ES256" ; signalg="sha256" ;; + P-384) jwkalg="ES384" ; signalg="sha384" ;; + P-521) jwkalg="ES512" ; signalg="sha512" ;; + *) error_exit "invalid curve algorithm type $crv";; + esac + pubtext="$(openssl ec -in "$skey" -noout -text 2>/dev/null \ + | awk '/^pub:/{p=1;next}/^ASN1 OID:/{p=0}p' \ + | tr -d ": \n\r")" + mid=$(( (${#pubtext} -2) / 2 + 2 )) + x64=$(echo "$pubtext" | cut -b 3-$mid | hex2bin | urlbase64) + y64=$(echo "$pubtext" | cut -b $((mid+1))-${#pubtext} | hex2bin | urlbase64) + jwk='{"crv":"'"$crv"'","kty":"EC","x":"'"$x64"'","y":"'"$y64"'"}' + else + error_exit "Invalid key file" + fi + thumbprint="$(printf "%s" "$jwk" | openssl dgst -sha256 -binary | urlbase64)" + debug "jwk alg = $jwkalg" +} + +graceful_exit() { # normal exit function. + clean_up + exit +} + +help_message() { # print out the help message + cat <<- _EOF_ + $PROGNAME ver. $VERSION + Obtain SSL certificates from the letsencrypt.org ACME server + + $(usage) + + Options: + -a, --all Check all certificates + -d, --debug Output debug information + -c, --create Create default config files + -f, --force Force renewal of cert (overrides expiry checks) + -h, --help Display this help message and exit + -q, --quiet Quiet mode (only outputs on error, success of new cert, or getssl was upgraded) + -Q, --mute Like -q, but also mute notification about successful upgrade + -r, --revoke "cert" "key" [CA_server] Revoke a certificate (the cert and key are required) + -u, --upgrade Upgrade getssl if a more recent version is available + -k, --keep "#" Maximum number of old getssl versions to keep when upgrading + -U, --nocheck Do not check if a more recent version is available + -w working_dir "Working directory" + + _EOF_ +} + +hex2bin() { # Remove spaces, add leading zero, escape as hex string ensuring no trailing new line char +# printf -- "$(cat | os_esed -e 's/[[:space:]]//g' -e 's/^(.(.{2})*)$/0\1/' -e 's/(.{2})/\\x\1/g')" + echo -e -n "$(cat | os_esed -e 's/[[:space:]]//g' -e 's/^(.(.{2})*)$/0\1/' -e 's/(.{2})/\\x\1/g')" +} + +info() { # write out info as long as the quiet flag has not been set. + if [[ ${_QUIET} -eq 0 ]]; then + echo -n "${_INFO_COLOR}" + echo "$@" + echo -n "${_RESET}" + fi +} + +json_awk() { # AWK json converter used for API2 - needs tidying up ;) +# shellcheck disable=SC2086 +echo $1 | awk ' +{ + tokenize($0) # while(get_token()) {print TOKEN} + if (0 == parse()) { + apply(JPATHS, NJPATHS) + } +} + +function apply (ary,size,i) { + for (i=1; i NTOKENS) to = NTOKENS + for (i = from; i < ITOKENS; i++) + context = context sprintf("%s ", TOKENS[i]) + context = context "<<" got ">> " + for (i = ITOKENS + 1; i <= to; i++) + context = context sprintf("%s ", TOKENS[i]) + scream("json_awk expected <" expected "> but got <" got "> at input token " ITOKENS "\n" context) +} + +function reset() { + TOKEN=""; delete TOKENS; NTOKENS=ITOKENS=0 + delete JPATHS; NJPATHS=0 + VALUE="" +} + +function scream(msg) { + FAILS[FILENAME] = FAILS[FILENAME] (FAILS[FILENAME]!="" ? "\n" : "") msg + msg = FILENAME ": " msg + print msg >"/dev/stderr" +} + +function tokenize(a1,pq,pb,ESCAPE,CHAR,STRING,NUMBER,KEYWORD,SPACE) { + SPACE="[[:space:]]+" + gsub(/\"[^[:cntrl:]\"\\]*((\\[^u[:cntrl:]]|\\u[0-9a-fA-F]{4})[^[:cntrl:]\"\\]*)*\"|-?(0|[1-9][0-9]*)([.][0-9]*)?([eE][+-]?[0-9]*)?|null|false|true|[[:space:]]+|./, "\n&", a1) + gsub("\n" SPACE, "\n", a1) + sub(/^\n/, "", a1) + ITOKENS=0 # get_token() helper + return NTOKENS = split(a1, TOKENS, /\n/) +}' +} + +json_get() { # get values from json + if [[ -z "$1" ]] || [[ "$1" == "null" ]]; then + echo "json was blank" + return + fi + if [[ $API = 1 ]]; then + # remove newlines, so it's a single chunk of JSON + json_data=$( echo "$1" | tr '\n' ' ') + # if $3 is defined, this is the section which the item is in. + if [[ -n "$3" ]]; then + jg_section=$(echo "$json_data" | awk -F"[}]" '{for(i=1;i<=NF;i++){if($i~/\"'"${3}"'\"/){print $i}}}') + if [[ "$2" == "uri" ]]; then + jg_subsect=$(echo "$jg_section" | awk -F"[,]" '{for(i=1;i<=NF;i++){if($i~/\"'"${2}"'\"/){print $(i)}}}') + jg_result=$(echo "$jg_subsect" | awk -F'"' '{print $4}') + else + jg_result=$(echo "$jg_section" | awk -F"[,:}]" '{for(i=1;i<=NF;i++){if($i~/\"'"${2}"'\"/){print $(i+1)}}}') + fi + else + jg_result=$(echo "$json_data" |awk -F"[,:}]" '{for(i=1;i<=NF;i++){if($i~/\"'"${2}"'\"/){print $(i+1)}}}') + fi + # check number of quotes + jg_q=${jg_result//[^\"]/} + # if 2 quotes, assume it's a quoted variable and just return the data within the quotes. + if [[ ${#jg_q} -eq 2 ]]; then + echo "$jg_result" | awk -F'"' '{print $2}' + else + echo "$jg_result" + fi + else + if [[ -n "$6" ]]; then + full=$(json_awk "$1") + section=$(echo "$full" | grep "\"$2\"" | grep "\"$3\"" | grep "\"$4\"" | awk -F"," '{print $2}') + echo "$full" | grep "^..${5}\",$section\]" | awk '{print $2}' | tr -d '"' + elif [[ -n "$5" ]]; then + full=$(json_awk "$1") + section=$(echo "$full" | grep "\"$2\"" | grep "\"$3\"" | grep "\"$4\"" | awk -F"," '{print $2}') + echo "$full" | grep "^..${2}\",$section" | grep "$5" | awk '{print $2}' | tr -d '"' + elif [[ -n "$3" ]]; then + json_awk "$1" | grep "^..${2}...${3}" | awk '{print $2}' | tr -d '"' + elif [[ -n "$2" ]]; then + json_awk "$1" | grep "^..${2}" | awk '{print $2}' | tr -d '"' + else + json_awk "$1" + fi + fi +} + +os_esed() { # Use different sed version for different os types (extended regex) + if [[ "$os" == "bsd" ]]; then # BSD requires -E flag for extended regex + sed -E "${@}" + elif [[ "$os" == "mac" ]]; then # MAC uses older BSD style sed. + sed -E "${@}" + else + sed -r "${@}" + fi +} + +purge_archive() { # purge archive of old, invalid, certificates + arcdir="$1/archive" + debug "purging archives in ${arcdir}/" + for padir in "$arcdir"/????_??_??_??_??; do + # check each directory + if [[ -d "$padir" ]]; then + tstamp=$(basename "$padir"| awk -F"_" '{print $1"-"$2"-"$3" "$4":"$5}') + if [[ "$os" == "bsd" ]]; then + direpoc=$(date -j -f "%F %H:%M" "$tstamp" +%s) + elif [[ "$os" == "mac" ]]; then + direpoc=$(date -j -f "%F %H:%M" "$tstamp" +%s) + else + direpoc=$(date -d "$tstamp" +%s) + fi + current_epoc=$(date "+%s") + # as certs currently valid for 90 days, purge anything older than 100 + purgedate=$((current_epoc - 60*60*24*100)) + if [[ "$direpoc" -lt "$purgedate" ]]; then + echo "purge $padir" + rm -rf "${padir:?}" + fi + fi + done +} + +reload_service() { # Runs a command to reload services ( via ssh if needed) + if [[ -n "$RELOAD_CMD" ]]; then + info "reloading SSL services" + if [[ "${RELOAD_CMD:0:4}" == "ssh:" ]] ; then + sshhost=$(echo "$RELOAD_CMD"| awk -F: '{print $2}') + command=${RELOAD_CMD:(( ${#sshhost} + 5))} + debug "running following command to reload cert" + debug "ssh $SSH_OPTS $sshhost ${command}" + # shellcheck disable=SC2029 + # shellcheck disable=SC2086 + ssh $SSH_OPTS "$sshhost" "${command}" 1>/dev/null 2>&1 + # allow 2 seconds for services to restart + sleep 2 + else + debug "running reload command $RELOAD_CMD" + if ! eval "$RELOAD_CMD" ; then + error_exit "error running $RELOAD_CMD" + fi + fi + fi +} + +revoke_certificate() { # revoke a certificate + debug "revoking cert $REVOKE_CERT" + debug "using key $REVOKE_KEY" + ACCOUNT_KEY="$REVOKE_KEY" + # need to set the revoke key as "account_key" since it's used in send_signed_request. + get_signing_params "$REVOKE_KEY" + TEMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t getssl) + debug "revoking from $CA" + rcertdata=$(openssl x509 -in "$REVOKE_CERT" -inform PEM -outform DER | urlbase64) + send_signed_request "$URL_revoke" "{\"resource\": \"revoke-cert\", \"certificate\": \"$rcertdata\"}" + if [[ $code -eq "200" ]]; then + info "certificate revoked" + else + error_exit "Revocation failed: $(echo "$response" | grep "detail")" + fi +} + +requires() { # check if required function is available + if [[ "$#" -gt 1 ]]; then # if more than 1 value, check list + for i in "$@"; do + if [[ "$i" == "${!#}" ]]; then # if on last variable then exit as not found + error_exit "this script requires one of: ${*:1:$(($#-1))}" + fi + res=$(command -v "$i" 2>/dev/null) + debug "checking for $i ... $res" + if [[ -n "$res" ]]; then # if function found, then set variable to function and return + debug "function $i found at $res - setting ${!#} to $i" + eval "${!#}=\$i" + return + fi + done + else # only one value, so check it. + result=$(command -v "$1" 2>/dev/null) + debug "checking for required $1 ... $result" + if [[ -z "$result" ]]; then + error_exit "This script requires $1 installed" + fi + fi +} + +set_server_type() { # uses SERVER_TYPE to set REMOTE_PORT and REMOTE_EXTRA + if [[ ${SERVER_TYPE} == "https" ]] || [[ ${SERVER_TYPE} == "webserver" ]]; then + REMOTE_PORT=443 + elif [[ ${SERVER_TYPE} == "ftp" ]]; then + REMOTE_PORT=21 + REMOTE_EXTRA="-starttls ftp" + elif [[ ${SERVER_TYPE} == "ftpi" ]]; then + REMOTE_PORT=990 + elif [[ ${SERVER_TYPE} == "imap" ]]; then + REMOTE_PORT=143 + REMOTE_EXTRA="-starttls imap" + elif [[ ${SERVER_TYPE} == "imaps" ]]; then + REMOTE_PORT=993 + elif [[ ${SERVER_TYPE} == "pop3" ]]; then + REMOTE_PORT=110 + REMOTE_EXTRA="-starttls pop3" + elif [[ ${SERVER_TYPE} == "pop3s" ]]; then + REMOTE_PORT=995 + elif [[ ${SERVER_TYPE} == "smtp" ]]; then + REMOTE_PORT=25 + REMOTE_EXTRA="-starttls smtp" + elif [[ ${SERVER_TYPE} == "smtps_deprecated" ]]; then + REMOTE_PORT=465 + elif [[ ${SERVER_TYPE} == "smtps" ]] || [[ ${SERVER_TYPE} == "smtp_submission" ]]; then + REMOTE_PORT=587 + REMOTE_EXTRA="-starttls smtp" + elif [[ ${SERVER_TYPE} == "xmpp" ]]; then + REMOTE_PORT=5222 + REMOTE_EXTRA="-starttls xmpp" + elif [[ ${SERVER_TYPE} == "xmpps" ]]; then + REMOTE_PORT=5269 + elif [[ ${SERVER_TYPE} == "ldaps" ]]; then + REMOTE_PORT=636 + elif [[ ${SERVER_TYPE} =~ ^[0-9]+$ ]]; then + REMOTE_PORT=${SERVER_TYPE} + REMOTE_EXTRA="CUSTOM-HTTP-PORT" + else + info "${DOMAIN}: unknown server type \"$SERVER_TYPE\" in SERVER_TYPE" + config_errors=true + fi +} + +send_signed_request() { # Sends a request to the ACME server, signed with your private key. + url=$1 + payload=$2 + needbase64=$3 + outfile=$4 # save response into this file (certificate data) + + debug url "$url" + + CURL_HEADER="$TEMP_DIR/curl.header" + dp="$TEMP_DIR/curl.dump" + + CURL="curl " + # shellcheck disable=SC2072 + if [[ "$($CURL -V | head -1 | cut -d' ' -f2 )" > "7.33" ]]; then + CURL="$CURL --http1.1 " + fi + + CURL="$CURL --user-agent $CURL_USERAGENT --silent --dump-header $CURL_HEADER " + + if [[ ${_USE_DEBUG} -eq 1 ]]; then + CURL="$CURL --trace-ascii $dp " + fi + + # convert payload to url base 64 + payload64="$(printf '%s' "${payload}" | urlbase64)" + + # get nonce from ACME server + if [[ $API -eq 1 ]]; then + nonceurl="$CA/directory" + nonce=$($CURL -I $nonceurl | grep "^Replay-Nonce:" | awk '{print $2}' | tr -d '\r\n ') + else # APIv2 + nonce=$($CURL -I "$URL_newNonce" | grep "^Replay-Nonce:" | awk '{print $2}' | tr -d '\r\n ') + fi + + nonceproblem="true" + while [[ "$nonceproblem" == "true" ]]; do + + # Build header with just our public key and algorithm information + header='{"alg": "'"$jwkalg"'", "jwk": '"$jwk"'}' + + # Build another header which also contains the previously received nonce and encode it as urlbase64 + if [[ $API -eq 1 ]]; then + protected='{"alg": "'"$jwkalg"'", "jwk": '"$jwk"', "nonce": "'"${nonce}"'", "url": "'"${url}"'"}' + protected64="$(printf '%s' "${protected}" | urlbase64)" + else # APIv2 + if [[ -z "$KID" ]]; then + debug "KID is blank, so using jwk" + protected='{"alg": "'"$jwkalg"'", "jwk": '"$jwk"', "nonce": "'"${nonce}"'", "url": "'"${url}"'"}' + protected64="$(printf '%s' "${protected}" | urlbase64)" + else + debug "using KID=${KID}" + protected="{\"alg\": \"$jwkalg\", \"kid\": \"$KID\",\"nonce\": \"${nonce}\", \"url\": \"${url}\"}" + protected64="$(printf '%s' "${protected}" | urlbase64)" + fi + fi + + # Sign header with nonce and our payload with our private key and encode signature as urlbase64 + sign_string "$(printf '%s' "${protected64}.${payload64}")" "${ACCOUNT_KEY}" "$signalg" + + # Send header + extended header + payload + signature to the acme-server + debug "payload = $payload" + if [[ $API -eq 1 ]]; then + body="{\"header\": ${header}," + body="${body}\"protected\": \"${protected64}\"," + body="${body}\"payload\": \"${payload64}\"," + body="${body}\"signature\": \"${signed64}\"}" + else + body="{" + body="${body}\"protected\": \"${protected64}\"," + body="${body}\"payload\": \"${payload64}\"," + body="${body}\"signature\": \"${signed64}\"}" + fi + + code="500" + loop_limit=5 + while [[ "$code" -eq 500 ]]; do + if [[ "$outfile" ]] ; then + $CURL -X POST -H "Content-Type: application/jose+json" --data "$body" "$url" > "$outfile" + response=$(cat "$outfile") + elif [[ "$needbase64" ]] ; then + response=$($CURL -X POST -H "Content-Type: application/jose+json" --data "$body" "$url" | urlbase64) + else + response=$($CURL -X POST -H "Content-Type: application/jose+json" --data "$body" "$url") + fi + + if [[ "$response" == "" ]]; then + error_exit "ERROR curl \"$url\" returned nothing" + fi + + responseHeaders=$(cat "$CURL_HEADER") + if [[ "$needbase64" && ${response##\()} != "{"* ]]; then + # response is in base64 too, decode (append = otherwise openssl truncates output) + response=$(echo "${response}=" | openssl base64 -d) + fi + + debug responseHeaders "$responseHeaders" + debug response "$response" + code=$(awk ' $1 ~ "^HTTP" {print $2}' "$CURL_HEADER" | tail -1) + debug code "$code" + if [[ "$code" == 4* && $response != *"error:badNonce"* ]]; then + detail=$(echo "$response" | grep "detail") + error_exit "ACME server returned error: ${code}: ${detail}" + fi + + if [[ $API -eq 1 ]]; then + response_status=$(json_get "$response" status \ + | head -1| awk -F'"' '{print $2}') + else # APIv2 + if [[ "$outfile" && "$response" ]]; then + debug "response written to $outfile" + elif [[ ${response##*()} == "{"* ]]; then + response_status=$(json_get "$response" status) + else + debug "response not in json format" + debug "$response" + fi + fi + debug "response status = $response_status" + if [[ "$code" -eq 500 ]]; then + info "error on acme server - trying again ...." + debug "loop_limit = $loop_limit" + sleep 5 + loop_limit=$((loop_limit - 1)) + if [[ $loop_limit -lt 1 ]]; then + error_exit "500 error from ACME server: $response" + fi + fi + done + if [[ $response == *"error:badNonce"* ]]; then + debug "bad nonce" + nonce=$(echo "$responseHeaders" | grep -i "^replay-nonce:" | awk '{print $2}' | tr -d '\r\n ') + debug "trying new nonce $nonce" + else + nonceproblem="false" + fi + done +} + +sign_string() { # sign a string with a given key and algorithm and return urlbase64 + # sets the result in variable signed64 + str=$1 + key=$2 + signalg=$3 + + if openssl rsa -in "${skey}" -noout 2>/dev/null ; then # RSA key + signed64="$(printf '%s' "${str}" | openssl dgst -"$signalg" -sign "$key" | urlbase64)" + elif openssl ec -in "${skey}" -noout 2>/dev/null ; then # Elliptic curve key. + signed=$(printf '%s' "${str}" | openssl dgst -"$signalg" -sign "$key" -hex | awk '{print $2}') + debug "EC signature $signed" + if [[ "${signed:4:4}" == "0220" ]]; then #sha256 + R=$(echo "$signed" | cut -c 9-72) + part2=$(echo "$signed" | cut -c 73-) + elif [[ "${signed:4:4}" == "0221" ]]; then #sha256 + R=$(echo "$signed" | cut -c 11-74) + part2=$(echo "$signed" | cut -c 75-) + elif [[ "${signed:4:4}" == "0230" ]]; then #sha384 + R=$(echo "$signed" | cut -c 9-104) + part2=$(echo "$signed" | cut -c 105-) + elif [[ "${signed:4:4}" == "0231" ]]; then #sha384 + R=$(echo "$signed" | cut -c 11-106) + part2=$(echo "$signed" | cut -c 107-) + elif [[ "${signed:6:4}" == "0241" ]]; then #sha512 + R=$(echo "$signed" | cut -c 11-140) + part2=$(echo "$signed" | cut -c 141-) + elif [[ "${signed:6:4}" == "0242" ]]; then #sha512 + R=$(echo "$signed" | cut -c 11-142) + part2=$(echo "$signed" | cut -c 143-) + else + error_exit "error in EC signing couldn't get R from $signed" + fi + debug "R $R" + + if [[ "${part2:0:4}" == "0220" ]]; then #sha256 + S=$(echo "$part2" | cut -c 5-68) + elif [[ "${part2:0:4}" == "0221" ]]; then #sha256 + S=$(echo "$part2" | cut -c 7-70) + elif [[ "${part2:0:4}" == "0230" ]]; then #sha384 + S=$(echo "$part2" | cut -c 5-100) + elif [[ "${part2:0:4}" == "0231" ]]; then #sha384 + S=$(echo "$part2" | cut -c 7-102) + elif [[ "${part2:0:4}" == "0241" ]]; then #sha512 + S=$(echo "$part2" | cut -c 5-136) + elif [[ "${part2:0:4}" == "0242" ]]; then #sha512 + S=$(echo "$part2" | cut -c 5-136) + else + info "print ${str} | openssl dgst -$signalg -sign $key -hex" + error_exit "error in EC signing couldn't get S from $signed" + fi + + debug "S $S" + signed64=$(printf '%s' "${R}${S}" | hex2bin | urlbase64 ) + debug "encoded RS $signed64" + fi +} + +signal_exit() { # Handle trapped signals + case $1 in + INT) + error_exit "Program interrupted by user" ;; + TERM) + echo -e "\n$PROGNAME: Program terminated" >&2 + graceful_exit ;; + *) + error_exit "$PROGNAME: Terminating on unknown signal" ;; + esac +} + +urlbase64() { # urlbase64: base64 encoded string with '+' replaced with '-' and '/' replaced with '_' + openssl base64 -e | tr -d '\n\r' | os_esed -e 's:=*$::g' -e 'y:+/:-_:' +} + +usage() { # echos out the program usage + echo "Usage: $PROGNAME [-h|--help] [-d|--debug] [-c|--create] [-f|--force] [-a|--all] [-q|--quiet]"\ + "[-Q|--mute] [-u|--upgrade] [-k|--keep #] [-U|--nocheck] [-r|--revoke cert key] [-w working_dir] domain" +} + +write_domain_template() { # write out a template file for a domain. + cat > "$1" <<- _EOF_domain_ + # Uncomment and modify any variables you need + # see https://github.com/srvrco/getssl/wiki/Config-variables for details + # see https://github.com/srvrco/getssl/wiki/Example-config-files for example configs + # + # The staging server is best for testing + #CA="https://acme-staging-v02.api.letsencrypt.org/directory" + # This server issues full certificates, however has rate limits + #CA="https://acme-v02.api.letsencrypt.org" + + #PRIVATE_KEY_ALG="rsa" + + # Additional domains - this could be multiple domains / subdomains in a comma separated list + # Note: this is Additional domains - so should not include the primary domain. + SANS="${EX_SANS}" + + # Acme Challenge Location. The first line for the domain, the following ones for each additional domain. + # If these start with ssh: then the next variable is assumed to be the hostname and the rest the location. + # An ssh key will be needed to provide you with access to the remote server. + # Optionally, you can specify a different userid for ssh/scp to use on the remote server before the @ sign. + # If left blank, the username on the local server will be used to authenticate against the remote server. + # If these start with ftp: then the next variables are ftpuserid:ftppassword:servername:ACL_location + # These should be of the form "/path/to/your/website/folder/.well-known/acme-challenge" + # where "/path/to/your/website/folder/" is the path, on your web server, to the web root for your domain. + #ACL=('/var/www/${DOMAIN}/web/.well-known/acme-challenge' + # 'ssh:server5:/var/www/${DOMAIN}/web/.well-known/acme-challenge' + # 'ssh:sshuserid@server5:/var/www/${DOMAIN}/web/.well-known/acme-challenge' + # 'ftp:ftpuserid:ftppassword:${DOMAIN}:/web/.well-known/acme-challenge') + + #Set USE_SINGLE_ACL="true" to use a single ACL for all checks + #USE_SINGLE_ACL="false" + + # Location for all your certs, these can either be on the server (full path name) + # or using ssh /sftp as for the ACL + #DOMAIN_CERT_LOCATION="/etc/ssl/${DOMAIN}.crt" # this is domain cert + #DOMAIN_KEY_LOCATION="/etc/ssl/${DOMAIN}.key" # this is domain key + #CA_CERT_LOCATION="/etc/ssl/chain.crt" # this is CA cert + #DOMAIN_CHAIN_LOCATION="" # this is the domain cert and CA cert + #DOMAIN_PEM_LOCATION="" # this is the domain key, domain cert and CA cert + + # The command needed to reload apache / nginx or whatever you use + #RELOAD_CMD="" + + # Define the server type. This can be https, ftp, ftpi, imap, imaps, pop3, pop3s, smtp, + # smtps_deprecated, smtps, smtp_submission, xmpp, xmpps, ldaps or a port number which + # will be checked for certificate expiry and also will be checked after + # an update to confirm correct certificate is running (if CHECK_REMOTE) is set to true + #SERVER_TYPE="https" + #CHECK_REMOTE="true" + #CHECK_REMOTE_WAIT="2" # wait 2 seconds before checking the remote server + _EOF_domain_ +} + +write_getssl_template() { # write out the main template file + cat > "$1" <<- _EOF_getssl_ + # Uncomment and modify any variables you need + # see https://github.com/srvrco/getssl/wiki/Config-variables for details + # + # The staging server is best for testing (hence set as default) + CA="https://acme-staging-v02.api.letsencrypt.org/directory" + # This server issues full certificates, however has rate limits + #CA="https://acme-v02.api.letsencrypt.org" + + #AGREEMENT="$AGREEMENT" + + # Set an email address associated with your account - generally set at account level rather than domain. + #ACCOUNT_EMAIL="me@example.com" + ACCOUNT_KEY_LENGTH=4096 + ACCOUNT_KEY="$WORKING_DIR/account.key" + PRIVATE_KEY_ALG="rsa" + #REUSE_PRIVATE_KEY="true" + + # The command needed to reload apache / nginx or whatever you use + #RELOAD_CMD="" + # The time period within which you want to allow renewal of a certificate + # this prevents hitting some of the rate limits. + RENEW_ALLOW="30" + + # Define the server type. This can be https, ftp, ftpi, imap, imaps, pop3, pop3s, smtp, + # smtps_deprecated, smtps, smtp_submission, xmpp, xmpps, ldaps or a port number which + # will be checked for certificate expiry and also will be checked after + # an update to confirm correct certificate is running (if CHECK_REMOTE) is set to true + SERVER_TYPE="https" + CHECK_REMOTE="true" + + # Use the following 3 variables if you want to validate via DNS + #VALIDATE_VIA_DNS="true" + #DNS_ADD_COMMAND= + #DNS_DEL_COMMAND= + _EOF_getssl_ +} + +write_openssl_conf() { # write out a minimal openssl conf + cat > "$1" <<- _EOF_openssl_conf_ + # minimal openssl.cnf file + distinguished_name = req_distinguished_name + [ req_distinguished_name ] + [v3_req] + [v3_ca] + _EOF_openssl_conf_ +} + +# Trap signals +trap "signal_exit TERM" TERM HUP +trap "signal_exit INT" INT + +# Parse command-line +while [[ -n ${1+defined} ]]; do + case $1 in + -h | --help) + help_message; graceful_exit ;; + -d | --debug) + _USE_DEBUG=1 + _INFO_COLOR=$(tput setaf 2);; + -c | --create) + _CREATE_CONFIG=1 ;; + -f | --force) + _FORCE_RENEW=1 ;; + -a | --all) + _CHECK_ALL=1 ;; + -k | --keep) + shift; _KEEP_VERSIONS="$1";; + -q | --quiet) + _QUIET=1 ;; + -Q | --mute) + _QUIET=1 + _MUTE=1 ;; + -r | --revoke) + _REVOKE=1 + shift + REVOKE_CERT="$1" + shift + REVOKE_KEY="$1" + shift + REVOKE_CA="$1" ;; + -u | --upgrade) + _UPGRADE=1 ;; + -U | --nocheck) + _UPGRADE_CHECK=0 ;; + -w) + shift; WORKING_DIR="$1" ;; + -*) + usage + error_exit "Unknown option $1" ;; + *) + if [[ -n $DOMAIN ]]; then + error_exit "invalid command line $DOMAIN - it appears to contain more than one domain" + fi + DOMAIN="$1" + if [[ -z $DOMAIN ]]; then + error_exit "invalid command line - it appears to contain a null variable" + fi ;; + esac + shift +done + +# Main logic +############ + +# Get the current OS, so the correct functions can be used for that OS. (sets the variable os) +get_os + +# check if "recent" version of bash. +#if [[ "${BASH_VERSINFO[0]}${BASH_VERSINFO[1]}" -lt 42 ]]; then +# info "this script is designed for bash v4.2 or later - earlier version may give errors" +#fi + +#check if required applications are included + +requires which +requires openssl +requires curl +requires nslookup drill dig host DNS_CHECK_FUNC +requires awk +requires tr +requires date +requires grep +requires sed +requires sort +requires mktemp + +# Check if upgrades are available (unless they have specified -U to ignore Upgrade checks) +if [[ $_UPGRADE_CHECK -eq 1 ]]; then + check_getssl_upgrade +fi + +# Revoke a certificate if requested +if [[ $_REVOKE -eq 1 ]]; then + if [[ -z $REVOKE_CA ]]; then + CA=$DEFAULT_REVOKE_CA + elif [[ "$REVOKE_CA" == "-d" ]]; then + _USE_DEBUG=1 + CA=$DEFAULT_REVOKE_CA + else + CA=$REVOKE_CA + fi + URL_revoke=$(curl --user-agent "$CURL_USERAGENT" "${CA}/directory" 2>/dev/null | grep "revoke-cert" | awk -F'"' '{print $4}') + revoke_certificate + graceful_exit +fi + +# get latest agreement from CA (as default) +AGREEMENT=$(curl --user-agent "$CURL_USERAGENT" -I "${CA}/terms" 2>/dev/null | awk 'tolower($1) ~ "location:" {print $2}'|tr -d '\r') + +# if nothing in command line, print help and exit. +if [[ -z "$DOMAIN" ]] && [[ ${_CHECK_ALL} -ne 1 ]]; then + help_message + graceful_exit +fi + +# if the "working directory" doesn't exist, then create it. +if [[ ! -d "$WORKING_DIR" ]]; then + debug "Making working directory - $WORKING_DIR" + mkdir -p "$WORKING_DIR" +fi + +# read any variables from config in working directory +if [[ -s "$WORKING_DIR/getssl.cfg" ]]; then + debug "reading config from $WORKING_DIR/getssl.cfg" + # shellcheck source=/dev/null + . "$WORKING_DIR/getssl.cfg" +fi + +# Define defaults for variables not set in the main config. +ACCOUNT_KEY="${ACCOUNT_KEY:=$WORKING_DIR/account.key}" +DOMAIN_STORAGE="${DOMAIN_STORAGE:=$WORKING_DIR}" +DOMAIN_DIR="$DOMAIN_STORAGE/$DOMAIN" +CERT_FILE="$DOMAIN_DIR/${DOMAIN}.crt" +FULL_CHAIN="$DOMAIN_DIR/fullchain.crt" +CA_CERT="$DOMAIN_DIR/chain.crt" +TEMP_DIR="$DOMAIN_DIR/tmp" +if [[ "$os" == "mingw" ]]; then + CSR_SUBJECT="//" +fi + +# Set the OPENSSL_CONF environment variable so openssl knows which config to use +export OPENSSL_CONF=$SSLCONF + +# if "-a" option then check other parameters and create run for each domain. +if [[ ${_CHECK_ALL} -eq 1 ]]; then + info "Check all certificates" + + if [[ ${_CREATE_CONFIG} -eq 1 ]]; then + error_exit "cannot combine -c|--create with -a|--all" + fi + + if [[ ${_FORCE_RENEW} -eq 1 ]]; then + error_exit "cannot combine -f|--force with -a|--all because of rate limits" + fi + + if [[ ! -d "$DOMAIN_STORAGE" ]]; then + error_exit "DOMAIN_STORAGE not found - $DOMAIN_STORAGE" + fi + + for dir in "${DOMAIN_STORAGE}"/*; do + if [[ -d "$dir" ]]; then + debug "Checking $dir" + cmd="$0 -U" # No update checks when calling recursively + if [[ ${_USE_DEBUG} -eq 1 ]]; then + cmd="$cmd -d" + fi + if [[ ${_QUIET} -eq 1 ]]; then + cmd="$cmd -q" + fi + # check if $dir looks like a domain name (contains a period) + if [[ $(basename "$dir") == *.* ]]; then + cmd="$cmd -w $WORKING_DIR $(basename "$dir")" + debug "CMD: $cmd" + eval "$cmd" + fi + fi + done + + graceful_exit +fi +# end of "-a" option (looping through all domains) + +# if "-c|--create" option used, then create config files. +if [[ ${_CREATE_CONFIG} -eq 1 ]]; then + # If main config file does not exists then create it. + if [[ ! -s "$WORKING_DIR/getssl.cfg" ]]; then + info "creating main config file $WORKING_DIR/getssl.cfg" + if [[ ! -s "$SSLCONF" ]]; then + SSLCONF="$WORKING_DIR/openssl.cnf" + write_openssl_conf "$SSLCONF" + fi + write_getssl_template "$WORKING_DIR/getssl.cfg" + fi + # If domain and domain config don't exist then create them. + if [[ ! -d "$DOMAIN_DIR" ]]; then + info "Making domain directory - $DOMAIN_DIR" + mkdir -p "$DOMAIN_DIR" + fi + if [[ -s "$DOMAIN_DIR/getssl.cfg" ]]; then + info "domain config already exists $DOMAIN_DIR/getssl.cfg" + else + info "creating domain config file in $DOMAIN_DIR/getssl.cfg" + # if domain has an existing cert, copy from domain and use to create defaults. + EX_CERT=$(echo \ + | openssl s_client -servername "${DOMAIN}" -connect "${DOMAIN}:443" 2>/dev/null \ + | openssl x509 2>/dev/null) + EX_SANS="www.${DOMAIN}" + if [[ -n "${EX_CERT}" ]]; then + EX_SANS=$(echo "$EX_CERT" \ + | openssl x509 -noout -text 2>/dev/null| grep "Subject Alternative Name" -A2 \ + | grep -Eo "DNS:[a-zA-Z 0-9.-]*" | sed "s@DNS:$DOMAIN@@g" | grep -v '^$' | cut -c 5-) + EX_SANS=${EX_SANS//$'\n'/','} + fi + write_domain_template "$DOMAIN_DIR/getssl.cfg" + fi + TEMP_DIR="$DOMAIN_DIR/tmp" + # end of "-c|--create" option, so exit + graceful_exit +fi +# end of "-c|--create" option to create config file. + +# if domain directory doesn't exist, then create it. +if [[ ! -d "$DOMAIN_DIR" ]]; then + debug "Making working directory - $DOMAIN_DIR" + mkdir -p "$DOMAIN_DIR" +fi + +# define a temporary directory, and if it doesn't exist, create it. +TEMP_DIR="$DOMAIN_DIR/tmp" +if [[ ! -d "${TEMP_DIR}" ]]; then + debug "Making temp directory - ${TEMP_DIR}" + mkdir -p "${TEMP_DIR}" +fi + +# read any variables from config in domain directory +if [[ -s "$DOMAIN_DIR/getssl.cfg" ]]; then + debug "reading config from $DOMAIN_DIR/getssl.cfg" + # shellcheck source=/dev/null + . "$DOMAIN_DIR/getssl.cfg" +fi + +# from SERVER_TYPE set REMOTE_PORT and REMOTE_EXTRA +set_server_type + +# check config for typical errors. +check_config + +if [[ -e "$DOMAIN_DIR/FORCE_RENEWAL" ]]; then + rm -f "$DOMAIN_DIR/FORCE_RENEWAL" || error_exit "problem deleting file $DOMAIN_DIR/FORCE_RENEWAL" + _FORCE_RENEW=1 + info "${DOMAIN}: forcing renewal (due to FORCE_RENEWAL file)" +fi + +# Obtain CA resource locations +ca_all_loc=$(curl --user-agent "$CURL_USERAGENT" "${CA}" 2>/dev/null) +debug "ca_all_loc from ${CA} gives $ca_all_loc" +# APIv1 +URL_new_reg=$(echo "$ca_all_loc" | grep "new-reg" | awk -F'"' '{print $4}') +URL_new_authz=$(echo "$ca_all_loc" | grep "new-authz" | awk -F'"' '{print $4}') +URL_new_cert=$(echo "$ca_all_loc" | grep "new-cert" | awk -F'"' '{print $4}') +#API v2 +URL_newAccount=$(echo "$ca_all_loc" | grep "newAccount" | awk -F'"' '{print $4}') +URL_newNonce=$(echo "$ca_all_loc" | grep "newNonce" | awk -F'"' '{print $4}') +URL_newOrder=$(echo "$ca_all_loc" | grep "newOrder" | awk -F'"' '{print $4}') +if [[ -z "$URL_new_reg" ]] && [[ -z "$URL_newAccount" ]]; then + ca_all_loc=$(curl --user-agent "$CURL_USERAGENT" "${CA}/directory" 2>/dev/null) + debug "ca_all_loc from ${CA}/directory gives $ca_all_loc" + # APIv1 + URL_new_reg=$(echo "$ca_all_loc" | grep "new-reg" | awk -F'"' '{print $4}') + URL_new_authz=$(echo "$ca_all_loc" | grep "new-authz" | awk -F'"' '{print $4}') + URL_new_cert=$(echo "$ca_all_loc" | grep "new-cert" | awk -F'"' '{print $4}') + #API v2 + URL_newAccount=$(echo "$ca_all_loc" | grep "newAccount" | awk -F'"' '{print $4}') + URL_newNonce=$(echo "$ca_all_loc" | grep "newNonce" | awk -F'"' '{print $4}') + URL_newOrder=$(echo "$ca_all_loc" | grep "newOrder" | awk -F'"' '{print $4}') +fi + +if [[ -n "$URL_new_reg" ]]; then + API=1 +elif [[ -n "$URL_newAccount" ]]; then + API=2 +else + info "unknown API version" + graceful_exit +fi +debug "Using API v$API" + +# if check_remote is true then connect and obtain the current certificate (if not forcing renewal) +if [[ "${CHECK_REMOTE}" == "true" ]] && [[ $_FORCE_RENEW -eq 0 ]]; then + debug "getting certificate for $DOMAIN from remote server" + # shellcheck disable=SC2086 + EX_CERT=$(echo \ + | openssl s_client -servername "${DOMAIN}" -connect "${DOMAIN}:${REMOTE_PORT}" ${REMOTE_EXTRA} 2>/dev/null \ + | openssl x509 2>/dev/null) + if [[ -n "$EX_CERT" ]]; then # if obtained a cert + if [[ -s "$CERT_FILE" ]]; then # if local exists + CERT_LOCAL=$(openssl x509 -noout -fingerprint < "$CERT_FILE" 2>/dev/null) + else # since local doesn't exist leave empty so that the domain validation will happen + CERT_LOCAL="" + fi + CERT_REMOTE=$(echo "$EX_CERT" | openssl x509 -noout -fingerprint 2>/dev/null) + if [[ "$CERT_LOCAL" == "$CERT_REMOTE" ]]; then + debug "certificate on server is same as the local cert" + else + # check if the certificate is for the right domain + EX_CERT_DOMAIN=$(echo "$EX_CERT" | openssl x509 -text \ + | sed -n -e 's/^ *Subject: .* CN=\([A-Za-z0-9.-]*\).*$/\1/p; /^ *DNS:.../ { s/ *DNS://g; y/,/\n/; p; }' \ + | sort -u | grep "^$DOMAIN\$") + if [[ "$EX_CERT_DOMAIN" == "$DOMAIN" ]]; then + # check renew-date on ex_cert and compare to local ( if local exists) + enddate_ex=$(echo "$EX_CERT" | openssl x509 -noout -enddate 2>/dev/null| cut -d= -f 2-) + enddate_ex_s=$(date_epoc "$enddate_ex") + debug "external cert has enddate $enddate_ex ( $enddate_ex_s ) " + if [[ -s "$CERT_FILE" ]]; then # if local exists + enddate_lc=$(openssl x509 -noout -enddate < "$CERT_FILE" 2>/dev/null| cut -d= -f 2-) + enddate_lc_s=$(date_epoc "$enddate_lc") + debug "local cert has enddate $enddate_lc ( $enddate_lc_s ) " + else + enddate_lc_s=0 + debug "local cert doesn't exist" + fi + if [[ "$enddate_ex_s" -eq "$enddate_lc_s" ]]; then + debug "certificates expire at the same time" + elif [[ "$enddate_ex_s" -gt "$enddate_lc_s" ]]; then + # remote has longer to expiry date than local copy. + debug "remote cert has longer to run than local cert - ignoring" + else + info "${DOMAIN}: remote cert expires sooner than local, attempting to upload from local" + copy_file_to_location "domain certificate" \ + "$CERT_FILE" \ + "$DOMAIN_CERT_LOCATION" + copy_file_to_location "private key" \ + "$DOMAIN_DIR/${DOMAIN}.key" \ + "$DOMAIN_KEY_LOCATION" + copy_file_to_location "CA certificate" "$CA_CERT" "$CA_CERT_LOCATION" + cat "$CERT_FILE" "$CA_CERT" > "$TEMP_DIR/${DOMAIN}_chain.pem" + copy_file_to_location "full pem" \ + "$TEMP_DIR/${DOMAIN}_chain.pem" \ + "$DOMAIN_CHAIN_LOCATION" + cat "$DOMAIN_DIR/${DOMAIN}.key" "$CERT_FILE" > "$TEMP_DIR/${DOMAIN}_K_C.pem" + copy_file_to_location "private key and domain cert pem" \ + "$TEMP_DIR/${DOMAIN}_K_C.pem" \ + "$DOMAIN_KEY_CERT_LOCATION" + cat "$DOMAIN_DIR/${DOMAIN}.key" "$CERT_FILE" "$CA_CERT" > "$TEMP_DIR/${DOMAIN}.pem" + copy_file_to_location "full pem" \ + "$TEMP_DIR/${DOMAIN}.pem" \ + "$DOMAIN_PEM_LOCATION" + reload_service + fi + else + info "${DOMAIN}: Certificate on remote domain does not match, ignoring remote certificate" + fi + fi + else + info "${DOMAIN}: no certificate obtained from host" + fi + # end of .... if obtained a cert +fi +# end of .... check_remote is true then connect and obtain the current certificate + +# if there is an existing certificate file, check details. +if [[ -s "$CERT_FILE" ]]; then + debug "certificate $CERT_FILE exists" + enddate=$(openssl x509 -in "$CERT_FILE" -noout -enddate 2>/dev/null| cut -d= -f 2-) + debug "local cert is valid until $enddate" + if [[ "$enddate" != "-" ]]; then + enddate_s=$(date_epoc "$enddate") + if [[ $(date_renew) -lt "$enddate_s" ]] && [[ $_FORCE_RENEW -ne 1 ]]; then + issuer=$(openssl x509 -in "$CERT_FILE" -noout -issuer 2>/dev/null) + if [[ "$issuer" == *"Fake LE Intermediate"* ]] && [[ "$CA" == "https://acme-v02.api.letsencrypt.org" ]]; then + debug "upgrading from fake cert to real" + else + info "${DOMAIN}: certificate is valid for more than $RENEW_ALLOW days (until $enddate)" + # everything is OK, so exit. + graceful_exit + fi + else + debug "${DOMAIN}: certificate needs renewal" + fi + fi +fi +# end of .... if there is an existing certificate file, check details. + +if [[ ! -t 0 ]] && [[ "$PREVENT_NON_INTERACTIVE_RENEWAL" = "true" ]]; then + errmsg="$DOMAIN due for renewal," + errmsg="${errmsg} but not completed due to PREVENT_NON_INTERACTIVE_RENEWAL=true in config" + error_exit "$errmsg" +fi + +# create account key if it doesn't exist. +if [[ -s "$ACCOUNT_KEY" ]]; then + debug "Account key exists at $ACCOUNT_KEY skipping generation" +else + info "creating account key $ACCOUNT_KEY" + create_key "$ACCOUNT_KEY_TYPE" "$ACCOUNT_KEY" "$ACCOUNT_KEY_LENGTH" +fi + +# if not reusing private key, then remove the old keys +if [[ "$REUSE_PRIVATE_KEY" != "true" ]]; then + if [[ -s "$DOMAIN_DIR/${DOMAIN}.key" ]]; then + rm -f "$DOMAIN_DIR/${DOMAIN}.key" + fi + if [[ -s "$DOMAIN_DIR/${DOMAIN}.ec.key" ]]; then + rm -f "$DOMAIN_DIR/${DOMAIN}.ecs.key" + fi +fi +# create new domain keys if they don't already exist +if [[ "$DUAL_RSA_ECDSA" == "false" ]]; then + create_key "${PRIVATE_KEY_ALG}" "$DOMAIN_DIR/${DOMAIN}.key" "$DOMAIN_KEY_LENGTH" +else + create_key "rsa" "$DOMAIN_DIR/${DOMAIN}.key" "$DOMAIN_KEY_LENGTH" + create_key "${PRIVATE_KEY_ALG}" "$DOMAIN_DIR/${DOMAIN}.ec.key" "$DOMAIN_KEY_LENGTH" +fi +# End of creating domain keys. + +#create SAN +if [[ -z "$SANS" ]]; then + SANLIST="subjectAltName=DNS:${DOMAIN}" +elif [[ "$IGNORE_DIRECTORY_DOMAIN" == "true" ]]; then + SANLIST="subjectAltName=DNS:${SANS//,/,DNS:}" +else + SANLIST="subjectAltName=DNS:${DOMAIN},DNS:${SANS//,/,DNS:}" +fi +debug "created SAN list = $SANLIST" + +#create CSR's +if [[ "$DUAL_RSA_ECDSA" == "false" ]]; then + create_csr "$DOMAIN_DIR/${DOMAIN}.csr" "$DOMAIN_DIR/${DOMAIN}.key" +else + create_csr "$DOMAIN_DIR/${DOMAIN}.csr" "$DOMAIN_DIR/${DOMAIN}.key" + create_csr "$DOMAIN_DIR/${DOMAIN}.ec.csr" "$DOMAIN_DIR/${DOMAIN}.ec.key" +fi + +# use account key to register with CA +# currently the code registers every time, and gets an "already registered" back if it has been. +get_signing_params "$ACCOUNT_KEY" + +info "Registering account" +# send the request to the ACME server. +if [[ $API -eq 1 ]]; then + if [[ "$ACCOUNT_EMAIL" ]] ; then + regjson='{"resource": "new-reg", "contact": ["mailto: '$ACCOUNT_EMAIL'"], "agreement": "'$AGREEMENT'"}' + else + regjson='{"resource": "new-reg", "agreement": "'$AGREEMENT'"}' + fi + send_signed_request "$URL_new_reg" "$regjson" +elif [[ $API -eq 2 ]]; then + if [[ "$ACCOUNT_EMAIL" ]] ; then + regjson='{"termsOfServiceAgreed": true, "contact": ["mailto: '$ACCOUNT_EMAIL'"]}' + else + regjson='{"termsOfServiceAgreed": true}' + fi + send_signed_request "$URL_newAccount" "$regjson" +else + debug "cant determine account API" + graceful_exit +fi + +if [[ "$code" == "" ]] || [[ "$code" == '201' ]] ; then + info "Registered" + KID=$(echo "$responseHeaders" | grep -i "^location" | awk '{print $2}'| tr -d '\r\n ') + debug "KID=_$KID}_" + echo "$response" > "$TEMP_DIR/account.json" +elif [[ "$code" == '409' ]] ; then + KID=$(echo "$responseHeaders" | grep -i "^location" | awk '{print $2}'| tr -d '\r\n ') + debug responseHeaders "$responseHeaders" + debug "Already registered KID=$KID" +elif [[ "$code" == '200' ]] ; then + KID=$(echo "$responseHeaders" | grep -i "^location" | awk '{print $2}'| tr -d '\r\n ') + debug responseHeaders "$responseHeaders" + debug "Already registered account, KID=${KID}" +else + error_exit "Error registering account ...$responseHeaders ... $(json_get "$response" detail)" +fi +# end of registering account with CA + +# verify each domain +info "Verify each domain" + +# loop through domains for cert ( from SANS list) +if [[ "$IGNORE_DIRECTORY_DOMAIN" == "true" ]]; then + alldomains=${SANS//,/ } +else + alldomains=$(echo "$DOMAIN,$SANS" | sed "s/,/ /g") +fi + +if [[ $API -eq 2 ]]; then + create_order +fi + +fulfill_challenges + +# Verification has been completed for all SANS, so request certificate. +info "Verification completed, obtaining certificate." + +#obtain the certificate. +get_certificate "$DOMAIN_DIR/${DOMAIN}.csr" \ + "$CERT_FILE" \ + "$CA_CERT" +if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then + info "Creating order for EC certificate" + if [[ $API -eq 2 ]]; then + create_order + fulfill_challenges + fi + info "obtaining EC certificate." + get_certificate "$DOMAIN_DIR/${DOMAIN}.ec.csr" \ + "${CERT_FILE%.*}.ec.crt" \ + "${CA_CERT%.*}.ec.crt" +fi + +# create Archive of new certs and keys. +cert_archive + +debug "Certificates obtained and archived locally, will now copy to specified locations" + +# copy certs to the correct location (creating concatenated files as required) +umask 077 + +copy_file_to_location "domain certificate" "$CERT_FILE" "$DOMAIN_CERT_LOCATION" +copy_file_to_location "private key" "$DOMAIN_DIR/${DOMAIN}.key" "$DOMAIN_KEY_LOCATION" +copy_file_to_location "CA certificate" "$CA_CERT" "$CA_CERT_LOCATION" +if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then + if [[ -n "$DOMAIN_CERT_LOCATION" ]]; then + copy_file_to_location "ec domain certificate" \ + "${CERT_FILE%.*}.ec.crt" \ + "${DOMAIN_CERT_LOCATION%.*}.ec.crt" + fi + if [[ -n "$DOMAIN_KEY_LOCATION" ]]; then + copy_file_to_location "ec private key" \ + "$DOMAIN_DIR/${DOMAIN}.ec.key" \ + "${DOMAIN_KEY_LOCATION%.*}.ec.key" + fi + if [[ -n "$CA_CERT_LOCATION" ]]; then + copy_file_to_location "ec CA certificate" \ + "${CA_CERT%.*}.ec.crt" \ + "${CA_CERT_LOCATION%.*}.ec.crt" + fi +fi + +# if DOMAIN_CHAIN_LOCATION is not blank, then create and copy file. +if [[ -n "$DOMAIN_CHAIN_LOCATION" ]]; then + if [[ "$(dirname "$DOMAIN_CHAIN_LOCATION")" == "." ]]; then + to_location="${DOMAIN_DIR}/${DOMAIN_CHAIN_LOCATION}" + else + to_location="${DOMAIN_CHAIN_LOCATION}" + fi + cat "$CERT_FILE" "$CA_CERT" > "$TEMP_DIR/${DOMAIN}_chain.pem" + copy_file_to_location "full chain" "$TEMP_DIR/${DOMAIN}_chain.pem" "$to_location" + if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then + cat "${CERT_FILE%.*}.ec.crt" "${CA_CERT%.*}.ec.crt" > "$TEMP_DIR/${DOMAIN}_chain.pem.ec" + copy_file_to_location "full chain" "$TEMP_DIR/${DOMAIN}_chain.pem.ec" "${to_location}.ec" + fi +fi +# if DOMAIN_KEY_CERT_LOCATION is not blank, then create and copy file. +if [[ -n "$DOMAIN_KEY_CERT_LOCATION" ]]; then + if [[ "$(dirname "$DOMAIN_KEY_CERT_LOCATION")" == "." ]]; then + to_location="${DOMAIN_DIR}/${DOMAIN_KEY_CERT_LOCATION}" + else + to_location="${DOMAIN_KEY_CERT_LOCATION}" + fi + cat "$DOMAIN_DIR/${DOMAIN}.key" "$CERT_FILE" > "$TEMP_DIR/${DOMAIN}_K_C.pem" + copy_file_to_location "private key and domain cert pem" "$TEMP_DIR/${DOMAIN}_K_C.pem" "$to_location" + if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then + cat "$DOMAIN_DIR/${DOMAIN}.ec.key" "${CERT_FILE%.*}.ec.crt" > "$TEMP_DIR/${DOMAIN}_K_C.pem.ec" + copy_file_to_location "private ec key and domain cert pem" "$TEMP_DIR/${DOMAIN}_K_C.pem.ec" "${to_location}.ec" + fi +fi +# if DOMAIN_PEM_LOCATION is not blank, then create and copy file. +if [[ -n "$DOMAIN_PEM_LOCATION" ]]; then + if [[ "$(dirname "$DOMAIN_PEM_LOCATION")" == "." ]]; then + to_location="${DOMAIN_DIR}/${DOMAIN_PEM_LOCATION}" + else + to_location="${DOMAIN_PEM_LOCATION}" + fi + cat "$DOMAIN_DIR/${DOMAIN}.key" "$CERT_FILE" "$CA_CERT" > "$TEMP_DIR/${DOMAIN}.pem" + copy_file_to_location "full key, cert and chain pem" "$TEMP_DIR/${DOMAIN}.pem" "$to_location" + if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then + cat "$DOMAIN_DIR/${DOMAIN}.ec.key" "${CERT_FILE%.*}.ec.crt" "${CA_CERT%.*}.ec.crt" > "$TEMP_DIR/${DOMAIN}.pem.ec" + copy_file_to_location "full ec key, cert and chain pem" "$TEMP_DIR/${DOMAIN}.pem.ec" "${to_location}.ec" + fi +fi +# end of copying certs. +umask "$ORIG_UMASK" +# Run reload command to restart apache / nginx or whatever system +reload_service + +# deactivate authorizations +if [[ "$DEACTIVATE_AUTH" == "true" ]]; then + debug "in deactivate list is $deactivate_url_list" + for deactivate_url in $deactivate_url_list; do + send_signed_request "$deactivate_url" "" + d=$(json_get "$response" "hostname") + info "deactivating domain $d" + debug "deactivating $deactivate_url" + send_signed_request "$deactivate_url" "{\"resource\": \"authz\", \"status\": \"deactivated\"}" + # check response + if [[ "$code" == "200" ]]; then + debug "Authorization deactivated" + else + error_exit "$domain: Deactivation error: $code" + fi + done +fi +# end of deactivating authorizations + +# Check if the certificate is installed correctly +if [[ ${CHECK_REMOTE} == "true" ]]; then + sleep "$CHECK_REMOTE_WAIT" + # shellcheck disable=SC2086 + CERT_REMOTE=$(echo \ + | openssl s_client -servername "${DOMAIN}" -connect "${DOMAIN}:${REMOTE_PORT}" ${REMOTE_EXTRA} 2>/dev/null \ + | openssl x509 -noout -fingerprint 2>/dev/null) + CERT_LOCAL=$(openssl x509 -noout -fingerprint < "$CERT_FILE" 2>/dev/null) + if [[ "$CERT_LOCAL" == "$CERT_REMOTE" ]]; then + info "${DOMAIN} - certificate installed OK on server" + else + error_exit "${DOMAIN} - certificate obtained but certificate on server is different from the new certificate" + fi +fi +# end of Check if the certificate is installed correctly + +# To have reached here, a certificate should have been successfully obtained. +# Use echo rather than info so that 'quiet' is ignored. +echo "certificate obtained for ${DOMAIN}" + +# gracefully exit ( tidying up temporary files etc). +graceful_exit diff --git a/test/1-simple-http01.bats b/test/1-simple-http01.bats index 40416b2..4c55304 100644 --- a/test/1-simple-http01.bats +++ b/test/1-simple-http01.bats @@ -17,6 +17,9 @@ setup() { init_getssl create_certificate assert_success + refute_output --regexp '[Ff][Aa][Ii][Ll][Ee][Dd]' + refute_output --regexp '[Ee][Rr][Rr][Oo][Rr]' + refute_output --regexp '[Ww][Aa][Rr][Nn][Ii][Nn][Gg]' } @@ -24,5 +27,8 @@ setup() { #!FIXME test certificate has been updated run ${CODE_DIR}/getssl -f $GETSSL_HOST assert_success + refute_output --regexp '[Ff][Aa][Ii][Ll][Ee][Dd]' + refute_output --regexp '[Ee][Rr][Rr][Oo][Rr]' + refute_output --regexp '[Ww][Aa][Rr][Nn][Ii][Nn][Gg]' cleanup_environment } diff --git a/test/2-simple-dns01.bats b/test/2-simple-dns01.bats index e1a37ec..9d9f44b 100644 --- a/test/2-simple-dns01.bats +++ b/test/2-simple-dns01.bats @@ -17,6 +17,9 @@ setup() { init_getssl create_certificate assert_success + refute_output --regexp '[Ff][Aa][Ii][Ll][Ee][Dd]' + refute_output --regexp '[Ee][Rr][Rr][Oo][Rr]' + refute_output --regexp '[Ww][Aa][Rr][Nn][Ii][Nn][Gg]' } @@ -24,5 +27,8 @@ setup() { #!FIXME test certificate has been updated run ${CODE_DIR}/getssl -f $GETSSL_HOST assert_success + refute_output --regexp '[Ff][Aa][Ii][Ll][Ee][Dd]' + refute_output --regexp '[Ee][Rr][Rr][Oo][Rr]' + refute_output --regexp '[Ww][Aa][Rr][Nn][Ii][Nn][Gg]' cleanup_environment } diff --git a/test/4-more-than-10-hosts.bats b/test/4-more-than-10-hosts.bats index 0493197..01e364d 100644 --- a/test/4-more-than-10-hosts.bats +++ b/test/4-more-than-10-hosts.bats @@ -23,6 +23,9 @@ setup() { init_getssl create_certificate assert_success + refute_output --regexp '[Ff][Aa][Ii][Ll][Ee][Dd]' + refute_output --regexp '[Ee][Rr][Rr][Oo][Rr]' + refute_output --regexp '[Ww][Aa][Rr][Nn][Ii][Nn][Gg]' } @@ -30,7 +33,9 @@ setup() { #!FIXME test certificate has been updated run ${CODE_DIR}/getssl -f $GETSSL_HOST assert_success - + refute_output --regexp '[Ff][Aa][Ii][Ll][Ee][Dd]' + refute_output --regexp '[Ee][Rr][Rr][Oo][Rr]' + refute_output --regexp '[Ww][Aa][Rr][Nn][Ii][Nn][Gg]' # Remove all the dns aliases cleanup_environment for prefix in a b c d e f g h i j k; do diff --git a/test/5-old-awk-error.bats b/test/5-old-awk-error.bats new file mode 100644 index 0000000..0f234a2 --- /dev/null +++ b/test/5-old-awk-error.bats @@ -0,0 +1,24 @@ +#! /usr/bin/env bats + +load '/bats-support/load.bash' +load '/bats-assert/load.bash' +load '/getssl/test/test_helper.bash' + + +# This is run for every test +setup() { + export CURL_CA_BUNDLE=/root/pebble-ca-bundle.crt +} + + +@test "Check getssl fails if an old version of awk is installed" { + CONFIG_FILE="getssl-http01.cfg" + # Make sure this test only runs on an image running an old version of awk + if [[ "$TEST_AWK" != "" ]]; then + setup_environment + init_getssl + create_certificate + assert_failure + assert_output "getssl: Your version of awk does not work with json_awk (see http://github.com/step-/JSON.awk/issues/6), please install a newer version of mawk or gawk" + fi +} diff --git a/test/Dockerfile-ubuntu18 b/test/Dockerfile-ubuntu18 index 6dd92c6..1b3765c 100644 --- a/test/Dockerfile-ubuntu18 +++ b/test/Dockerfile-ubuntu18 @@ -13,6 +13,9 @@ RUN mkdir /etc/nginx/pki RUN mkdir /etc/nginx/pki/private COPY ./test/test-config/nginx-ubuntu-no-ssl /etc/nginx/sites-enabled/default +# Prevent "Can't load /root/.rnd into RNG" error from openssl +RUN touch /root/.rnd + # BATS (Bash Automated Testings) RUN git clone https://github.com/bats-core/bats-core.git /bats-core RUN git clone https://github.com/jasonkarns/bats-support /bats-support diff --git a/test/Dockerfile-ubuntu18-no-gawk b/test/Dockerfile-ubuntu18-no-gawk new file mode 100644 index 0000000..809708a --- /dev/null +++ b/test/Dockerfile-ubuntu18-no-gawk @@ -0,0 +1,17 @@ +FROM ubuntu:bionic +# bionic = latest 18 version + +# Update and install required software +RUN apt-get update --fix-missing +RUN apt-get install -y git curl dnsutils wget nginx-light + +WORKDIR /root + +# BATS (Bash Automated Testings) +RUN git clone https://github.com/bats-core/bats-core.git /bats-core +RUN git clone https://github.com/jasonkarns/bats-support /bats-support +RUN git clone https://github.com/jasonkarns/bats-assert-1 /bats-assert +RUN /bats-core/install.sh /usr/local + +# Run eternal loop - for testing +CMD ["/bin/bash", "-c", "while :; do sleep 10; done"] diff --git a/test/README.md b/test/README.md index 71c75f7..98ff929 100644 --- a/test/README.md +++ b/test/README.md @@ -1,22 +1,40 @@ # Testing -This directory contains a simple test script which tests creating certificates with Pebble (testing version of the LetsEncrypt server) +This directory contains a simple test script which tests creating +certificates with Pebble (testing version of the LetsEncrypt server) Start up pebble, the challdnstest server for DNS challenges -`docker-compose -f "docker-compose.yml" up -d --build` + +```sh +docker-compose -f "docker-compose.yml" up -d --build +``` Run the tests -`docker exec -it getssl bats /getssl/test` + +```sh +test/run-all-tests.sh +``` Run individual test -`docker exec -it getssl bats /getssl/test/` -Debug (need to set CURL_CA_BUNDLE as pebble uses a local certificate, otherwise you get a "unknown API version" error) -`docker exec -it getssl /bin/bash` -`export CURL_CA_BUNDLE=/root/pebble-ca-bundle.crt` -`/getssl/getssl -d getssl` +```sh +docker exec -it getssl bats /getssl/test/ +``` + +Debug (uses helper script to set `CURL_CA_BUNDLE` as pebble uses a local certificate, +otherwise you get a "unknown API version" error) + +```sh +docker exec -it getssl- /getssl/test/debug-test.sh ` + +eg. + +```sh +docker exec -it getssl-ubuntu18 /getssl/test/debug-test.sh getssl-http01.cfg +``` + +## TODO -# TODO 1. Test RHEL6, Debian as well 2. Test SSH, SFTP 3. Test wildcards diff --git a/test/run-test.sh b/test/debug-test.sh similarity index 79% rename from test/run-test.sh rename to test/debug-test.sh index 3548e1c..23d1983 100644 --- a/test/run-test.sh +++ b/test/debug-test.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # This runs getssl outside of the BATS framework for debugging, etc, against pebble -# Usage: /getssl/test/run-test.sh getssl-http01.cfg +# Usage: /getssl/test/debug-test.sh getssl-http01.cfg CONFIG_FILE=$1 source /getssl/test/test_helper.bash @@ -11,4 +11,4 @@ export CURL_CA_BUNDLE=/root/pebble-ca-bundle.crt "${CODE_DIR}/getssl" -c "$GETSSL_HOST" 3>&1 cp "${CODE_DIR}/test/test-config/${CONFIG_FILE}" "${INSTALL_DIR}/.getssl/${GETSSL_HOST}/getssl.cfg" -"${CODE_DIR}/getssl" "$GETSSL_HOST" 3>&1 +"${CODE_DIR}/getssl" -f "$GETSSL_HOST" 3>&1 diff --git a/test/run-all-tests.sh b/test/run-all-tests.sh new file mode 100644 index 0000000..7372e5b --- /dev/null +++ b/test/run-all-tests.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +docker exec -it getssl-centos6 bats /getssl/test +docker exec -it getssl-ubuntu18 bats /getssl/test +docker exec -it getssl-ubuntu18-no-gawk bats /getssl/test/5-old-awk-error.bats