Script to show TLS and HTTP(S) details (Take 2)

This is a re-post: the script has been significantly re-written. The output is still the same, but the code is more efficient and will behave better if it encounters sites that use port 443 without a cert.

I'm pleased with the following script. It evolved over months of checking our websites for security and technical details. Let's start with the output, which looks like this:

$ tlsdetails gilesorr.com
Using OpenSSL:  /usr/bin/openssl
Expiry Date:    Dec  5 02:06:59 2019 GMT (83 days)
Issuer:         Let's Encrypt, CN
TLS Versions:   tls1_2 tls1_1 tls1  (tried but unavailable: tls1_3 ssl3 ssl2 )
HTTP Version:   2

(TLS 1.3 should be coming soon to gilesorr.com, and TLS 1 will go away.)

If you need extensive details about a site's SSL/TLS support, use a site like SSL Labs Qualys Test. (There are others - this is my preferred one at the moment.)

Here's the script itself - look below the script for the explanation of each of the pieces.

#!/usr/bin/env bash
# Purpose:
#     Check TLS certificate and HTTPS details.
# Source:
#     https://www.gilesorr.com/blog/tls-https-details2.html


######################################################################
#                       Version Check
######################################################################
# Apple's own 'openssl' was incredibly old (0.9.8) until ~2019-06 when they
# changed to LibreSSL which is better, but doesn't support TLS 1.3.  Brew
# respects Apple's version, so we have to go on a version hunt.

OPENSSL=""

# Put most desirable/least-likely last:
for opensslbinary in /usr/local/opt/openssl@1.1/bin/openssl /usr/local/opt/openssl/bin/openssl /usr/bin/openssl
do
    # Every version of OpenSSL I've checked (several across multiple
    # OpenSSL versions and Apple's LibreSSL) respond to 'openssl version'
    # with '<brand> <version-no> ...' - there may or may not be stuff after
    # those two items (usually a build date, but not with Libre).
    #
    if [ -x "${opensslbinary}" ]
    then
        read -r brand version rest <<< "$(echo "" | "${opensslbinary}" version)"
        case "${brand}:${version}" in
            OpenSSL:1.1*) OPENSSL="${opensslbinary}" ;;
        esac
    fi
done
if [ -z "${OPENSSL}" ]
then
    echo "This script requires OpenSSL v1.1, which was not found."
    echo "If on Mac, please 'brew install openssl@1.1'"
    exit 1
fi
echo "Using OpenSSL:  ${OPENSSL}"

######################################################################
#                            Help
######################################################################

help() {
    cat << EOF
Usage:
    $(basename "${0}") [-h]|<domain-name>

Show HTTP(s) and TLS certificate details.
Do not include the 'http(s)://' leader on the domain name.

-h            show this help and exit
EOF
}


######################################################################
#                    is hostname valid
######################################################################

ishostnamevalid() {
    # given a hostname, try to find the IP and use that to determine if the
    # hostname is actually valid.  Return 0 for a valid hostname, 1 for an
    # invalid hostname.

    IP="$( dig +short "${1}")"
    if [ -z "${IP}" ]
    then
        # hostname isn't valid - no IP returned
        echo 1
    else
        # IP returned, valid hostname
        echo 0
    fi
}


######################################################################
#                       Utility Functions
######################################################################

expiry_date() {
    echo "${1}" | ${OPENSSL} x509 -noout -dates | awk 'BEGIN { FS="=" } /notAfter/ { print $2 }'
}

days_to_expiry() {
    expiry_date="$(expiry_date "${1}")"
    if date --version 2>/dev/null | grep -q GNU
    then
        # Linux (or at least GNU)
        expiry_epoch_seconds=$(date --date="${expiry_date}" "+%s")
    else
        # Assuming the Mac version:
        expiry_epoch_seconds=$(date -jf '%b %e %H:%M:%S %Y %Z' "${expiry_date}" "+%s")
    fi
    # and we convert to seconds from the Unix Epoch ...
    now_epoch_seconds=$(date "+%s")
    seconds_to_expiry=$(( expiry_epoch_seconds - now_epoch_seconds ))
    echo "$(( seconds_to_expiry / 60 / 60 / 24 ))"
}

issuer() {
    echo "${1}" | ${OPENSSL} x509 -noout -issuer | awk -F "=" '{ print $4 }' | sed -e 's@/.*@@'
}

tlsversions() {
    successful=""
    failed=""
    for tlsversion in ssl2 ssl3 tls1 tls1_1 tls1_2 tls1_3
    do
        if echo | ${OPENSSL} s_client -connect "${1}":443 -${tlsversion} > /dev/null 2> /dev/null
        then
            successful="${tlsversion} ${successful}"
        else
            failed="${tlsversion} ${failed}"
        fi
    done
    echo "${successful} (tried but unavailable: ${failed})"
}

httpversion() {
    # This 'curl' command returns nothing but a number: '1.1' for most
    # connections, but '2' for HTTP2 sites - and '0' for https:// requests
    # on an unencrypted site.
    unEncNum=$(curl -sI "${1}"         -o/dev/null -w '%{http_version}')
    EncNum=$(curl -sI   "https://${1}" -o/dev/null -w '%{http_version}')
    # since possible return values of EncNum include '1.1', which isn't a
    # valid number in Bash, this is a string comparison:
    if [ "${EncNum}" -eq "0" ]
    then
        echo "${unEncNum}"
    else
        echo "${EncNum}"
    fi
}


######################################################################
#                    Process the command line
######################################################################

if [ $# -lt 1 ]
then
    help
    exit 1
fi

# http://wiki.bash-hackers.org/howto/getopts_tutorial
while getopts ":h" opt
do
    case ${opt} in
        h)
            help
            exit 0
            ;;

        \?)
            echo "invalid option: -${OPTARG}" >&2
            help
            exit 1
            ;;

        :)
            echo "option -${OPTARG} requires an argument." >&2
            help
            exit 1
            ;;
    esac
done

domain_name="${1}"

if [ "$(ishostnamevalid "${domain_name}")" -eq "0" ]
then
    # it's extremely difficult to capture stderr and stdout into two
    # separate variables at once, so use a tmpfile:
    tmpfile="$(/usr/bin/mktemp "/tmp/$(basename "${0}").XXXXXXXX")"
    sclient_out="$(echo | ${OPENSSL} s_client -connect "${domain_name}:443" -servername "${domain_name}" 2>"${tmpfile}")"
    sclient_ret=$?
    sclient_err=$(cat "${tmpfile}")
    rm "${tmpfile}"
    if [ ${sclient_ret} -ne 0 ]
    then
        echo "There was a problem getting the certificate."
        exit 1
    fi
    if [ -z "${sclient_out}" ]
    then
        echo "No certificate returned."
    else
        echo "Expiry Date:    $(expiry_date "${sclient_out}") ($(days_to_expiry "${sclient_out}") days)"
        echo "Issuer:        $(issuer "${sclient_out}")"
        echo "TLS Versions:   $(tlsversions "${domain_name}")"
        echo "HTTP Version:   $(httpversion "${domain_name}")"
    fi
else
    echo "'${1}' appears to be an invalid domain."
fi

(cloc tells me that this is 128 lines of code to generate five lines of output ...)

This has been significantly rewritten after a lot of very helpful input from the TLUG mailing list. (The original is here.)

I wrote this script to run on both Linux and Mac. This is significantly complicated by Apple's weird history with OpenSSL (not entirely Apple's fault as OpenSSL is a complex mess). Up until fairly recently (it changed in the last four or five months), OS X included the spectacularly old version 0.9.8 of OpenSSL. This was ... not very useful, as there are a number of things it couldn't do. So most of us who have to use openssl instead do brew install openssl@1.1. But because there's already an existing OS X binary, brew doesn't link their openssl binary onto your path so you have to go through various machinations to use it. A very recent version of OS X (10.14.5? I'm not entirely sure when it changed) now includes the LibreSSL openssl binary. This is an improvement, but this new binary doesn't support TLS 1.3 ... I get the frustrations with OpenSSL, but that's a huge lack in the LibreSSL binary. So my script insists on an OpenSSL binary, and a version of at least 1.1 (1.0.x doesn't support TLS 1.3).

After the OpenSSL version check, it's probably best to start reading the script from the "Process the command line" section. We use getopts to parse the command line and then proceed only if the provided host name is considered valid. This is tested by the ishostnamevalid() function using dig +short host.name to see if an IP address is returned - if it is, the host name is good.

The next step is to grab the output of the openssl s_client ... command for the host name. The output is large and technically complex, and includes a lot more information than I want. It even includes the TLS protocol used, but since we want to determine all the TLS protocols available, I didn't use that information from this source.

The expiry_date() function uses some text mangling to pull the wanted date. The days_to_expiry() function uses the date command (different versions on Mac or Linux, so annoying) to convert the expiry date of the certificate into seconds since the Unix Epoch (that's a huge subject by itself, but once you understand it - as stupid as it initially seems - it's often the best way to handle dates on a Unix system). Then we convert the date NOW to Unix Epoch time, get the difference between the two, and convert those seconds back into a number of days until the certificate expires.

(The expiry date of our certificates was actually the original inspiration for this script: we had a couple expire without anyone noticing, which is very embarrassing. Running this weekly against a long list of host names and noting how soon expiries are coming up has helped.)

issuer() uses openssl and text mangling to get the name authority for the certificate.

tlsversions() uses openssl to try to connect to the domain using each of the current TLS and SSL protocols, and then prints success or failure for each. It's a GOOD THING (TM) that 'ssl3' and 'ssl2' fail: they're incredibly outdated and broken. I disagree with Google's decision to still allow TLS 1.0 - our web stats show that very few people are using it, and it's extremely weak. But enough people are still using old web clients that supporting TLS 1.1 is still - sadly - recommended.

Finally, httpversion() uses curl to determine what version of HTTP is available from the site. I didn't bother listing all versions available from the host name because ALL sites support 1.1. So this test is essentially a binary question, "do they support HTTP2 or not?" If you look closely, you'll see that the host name has "https://" prefixed to it before testing for HTTP2: in theory, HTTP2 can be implemented without encryption, but nobody has done that.