#!/bin/bash

# HAProxy OCSP Stapling Updater
# Copyright (c) 2015 Pier Carlo Chiodi - http://www.pierky.com
#
# https://github.com/pierky/haproxy-ocsp-stapling-updater

set -o nounset

VERSION="0.4.1-pre1"

PROGNAME="hapos-upd"

if [ -z ${OPENSSL_BIN+x} ]; then
    OPENSSL_BIN="openssl"
fi

SOCAT_BIN="socat"

CERT=""
VAFILE=""
HAPROXY_ADMIN_SOCKET_DEFAULT="/run/haproxy/admin.sock"
HAPROXY_ADMIN_SOCKET="$HAPROXY_ADMIN_SOCKET_DEFAULT"
GOOD_ONLY=0
SYSLOG_PRIO=""
DEBUG=0
KEEP_TEMP=0
OCSP_URL=""
OCSP_HOST=""
VERIFY=1
TMP=""
SKIP_UPDATE=0
PARTIAL_CHAIN=""

function Quit() {
    if [ $KEEP_TEMP -eq 0 ]; then
        if [ -n "$TMP" ]; then
            rm -r $TMP &>/dev/null
        fi
    fi
    exit $1
}

function LogError() {
    MSG="$1"

    if [ -z "$SYSLOG_PRIO" ]; then
        echo "$MSG" >&2
    else
        logger -p "$SYSLOG_PRIO" -s -- "$PROGNAME - $MSG"
    fi

    echo "$MSG" >>$TMP/log
}

function Error() {
    if [ $1 -eq 9 ]; then
        MSG="Error: $2"
    else
        MSG="Error processing '$CERT': $2"
    fi

    LogError "$MSG"

    if [ $1 -eq 9 ]; then
        echo "Run $PROGNAME -h for help" >&2
    fi

    Quit $1
}

function Debug() {
    if [ $DEBUG -eq 1 ]; then
        echo "$1"
    fi
    echo "$1" >>$TMP/log
}

function Trap() {
    Debug "Aborting"
    Quit 9
}

function Usage() {
    echo "
HAProxy OCSP Stapling Updater - $VERSION
Copyright (c) 2015 Pier Carlo Chiodi - http://www.pierky.com

https://github.com/pierky/haproxy-ocsp-stapling-updater

Usage:
 $PROGNAME [options] --cert crt_full_path

This script extracts and queries the OCSP server present in a 
certificate to obtain its revocation status, then updates HAProxy by
writing the '.issuer' and the '.ocsp' files and by sending it the 
'set ssl ocsp-response' command through the local UNIX admin socket.

The crt_full_path argument is the full path to the certificate bundle
used in haproxy 'crt' setting. End-entity (EE) certificate plus any 
intermediate CA certificates must be concatenated there.
An OCSP query is sent to the OCSP server given on the command line 
(--ocsp-url and --ocsp-host argument); if these arguments are missing, 
URL and Host header values are automatically extracted from the 
certificate.
If the '.issuer' file already exists it's used to build the OCSP 
request, otherwise the chain is extracted from crt_full_path and used
to identify the issuer.
Finally, it writes the related '.issuer' and .'ocsp' files and updates
haproxy, using 'socat' and the local UNIX socket (--socket argument, 
default $HAPROXY_ADMIN_SOCKET_DEFAULT).

Exit codes:
 0       OK
 1       openssl certificates handling error
 2       OCSP server URL not found
 3       string parsing / PEM manipulation error
 4       OCSP error
 5       haproxy management error
 9       program error (wrong arguments, missing dependencies)

Options:

 -d, --debug           : don't do anything, print debug messages only.

     --keep-temp       : keep temporary directory after exiting (for 
                         debug purposes).

 -g, --good-only       : do not update haproxy if OCSP response 
                         certificate status value is not 'good'.

 -l, --syslog priority : log errors to syslog system log module.
                         The priority may be specified numerically 
                         or as a facility.level pair (e.g. 
                         local7.error).

     --ocsp-url url    : OCSP server URL; use this instead of the
                         one in the EE certificate.

     --ocsp-host host  : OCSP server hostname to be used in the 
                         'Host:' header; use this instead of the one
                         extracted from the OCSP server URL.

     --partial-chain   : Allow partial certificate chain if at least one certificate 
                         is in trusted store. Useful when validating an intermediate
                         certificate without the root CA.
                         
 -s, --socket file     : haproxy admin socket. If omitted,
                         $HAPROXY_ADMIN_SOCKET_DEFAULT is used by default.
                         This script is distributed with only one 
                         method to update haproxy: using 'socat' 
                         with a local admin-level UNIX socket.
                         Feel free to implement other mechanisms as 
                         needed! The right section in the code is 
                         \"UPDATE HAPROXY\", at the end of the script.
 
 -v, --VAfile file     : same as the openssl ocsp -VAfile option 
                         with 'file' as argument. For more details: 
                         'man ocsp'.
                         If file = \"-\" then the chain extracted 
                         from the certificate's bundle (or .issuer 
                         file) is used (useful for OCSP responses 
                         that don't include the signer certificate).

      --noverify       : Do not verify OCSP response.

 -S, --skip-update     : Do not notify haproxy of the new OCSP response.

 -h, --help            : this help."
}

trap Trap INT TERM

TMP="`mktemp -d -q -t $PROGNAME.XXXXXXXXXX`"

# COMMAND LINE PROCESSING
# ----------------------------------

while [[ $# > 0 ]]
do

    case "$1" in
        -h|--help)
            Usage
            Quit 0
            ;;

        -d|--debug)
            DEBUG=1
            ;;

        --keep-temp)
            KEEP_TEMP=1
            ;;

        -g|--good-only)
            GOOD_ONLY=1
            ;;

        --noverify)
            VERIFY=0
            ;;

        --partial-chain)
            PARTIAL_CHAIN="-partial_chain"
            ;;

        -l|--syslog)
            if [ $# -le 1 ]; then
                Error 9 "mandatory value is missing for $1 argument"
            fi
            SYSLOG_PRIO="$2"
            shift
            ;;

        --ocsp-url)
            if [ $# -le 1 ]; then
                Error 9 "mandatory value is missing for $1 argument"
            fi
            OCSP_URL="$2"
            shift
            ;;

        --ocsp-host)
            if [ $# -le 1 ]; then
                Error 9 "mandatory value is missing for $1 argument"
            fi
            OCSP_HOST="$2"
            shift
            ;;

        -c|--cert)
            if [ $# -le 1 ]; then
                Error 9 "mandatory value is missing for $1 argument"
            fi
            CERT="$2"
            shift
            ;;

        -v|--VAfile)
            if [ $# -le 1 ]; then
                Error 9 "mandatory value is missing for $1 argument"
            fi
            VAFILE="$2"
            if [ "$VAFILE" == "-" ]; then
                VAFILE="$TMP/chain.pem"
            else
                if [ ! -e "$VAFILE" ]; then
                    Error 9 "VAfile does not exists: $VAFILE"
                fi
            fi
            shift
            ;;

        -s|--socket)
            if [ $# -le 1 ]; then
                Error 9 "mandatory value is missing for $1 argument"
            fi
            HAPROXY_ADMIN_SOCKET="$2"
            shift
            ;;

        -S|--skip-update)
            SKIP_UPDATE=1
            ;;

        *)
            Error 9 "unknown option: $1"
    esac
    
    shift
done

Debug "Temporary directory: $TMP"

$OPENSSL_BIN version | grep OpenSSL &>>$TMP/log

if [ $? -ne 0 ]; then
    Error 9 "openssl binary not found; adjust OPENSSL_BIN variable in the script"
fi

$SOCAT_BIN -V | grep socat &>>$TMP/log

if [ $? -ne 0 ]; then
    Error 9 "socat binary not found; adjust SOCAT_BIN variable in the script"
fi

if [ -z "$CERT" ]; then
    Error 9 "certificate not provided (--cert argument)"
fi

# CURRENT RESPONSE EXPIRED?
# ----------------------------------

ISNEW=1
if [ -e $CERT.ocsp ]; then
    ISNEW=0
    Debug "An OCSP response already exists: checking its expiration."

    $OPENSSL_BIN ocsp -respin $CERT.ocsp -text -noverify | \
        grep "Next Update:" &>>$TMP/log

    if [ $? -eq 0 ]; then
        CURR_EXP=`$OPENSSL_BIN ocsp -respin $CERT.ocsp -text -noverify | grep "Next Update:" | cut -d ':' -f 2-`
        CURR_EXP_EPOCH=`date --date="$CURR_EXP" +%s`

        if [ $? -ne 0 ]; then
            Error 3 "can't parse Next Update from current OCSP response"
        fi

        if [ $CURR_EXP_EPOCH -lt `date +%s` ]; then
            Debug "Current OCSP response expiration: $CURR_EXP - expired"
            LogError "current OCSP response is expired: please consider running this script more frequently"
        else
            Debug "Current OCSP response expiration: $CURR_EXP - NOT expired"
        fi
    fi
fi

# EXTRACT EE CERTIFICATE INFO
# ----------------------------------

# extract EE certificate
$OPENSSL_BIN x509 -in $CERT -outform PEM -out $TMP/ee.pem &>>$TMP/log

if [ $? -ne 0 ]; then
    Error 1 "can't extract EE certificate from $CERT"
fi

# get OCSP server URL
if [ -z "$OCSP_URL" ]; then
    OCSP_URL="`$OPENSSL_BIN x509 -in $TMP/ee.pem -ocsp_uri -noout`"

    if [ $? -ne 0 ]; then
        Error 1 "can't obtain OCSP server URL from $CERT"
    fi

    if [ -z "$OCSP_URL" ]; then
        Error 2 "OCSP server URL not found in the EE certificate"
    fi

    Debug "OCSP server URL found: $OCSP_URL"
else
    Debug "Using OCSP server URL from command line: $OCSP_URL"
fi

# check OCSP server URL format (http:// or https://)
echo "$OCSP_URL" | egrep -i "(http://|https://)" &>/dev/null

if [ $? -ne 0 ]; then
    Error 3 "OCSP server URL not in http[s]:// format"
fi

# get OCSP server URL host name
if [ -z "$OCSP_HOST" ]; then
    OCSP_HOST="`echo "$OCSP_URL" | egrep -i "(http://|https://)" | cut -d'/' -f 3`"

    if [ $? -ne 0 -o -z "$OCSP_HOST" ]; then
        Error 3 "can't extract hostname from OCSP server URL $OCSP_URL"
    fi

    Debug "OCSP server hostname: $OCSP_HOST"
else
    Debug "Using OCSP server hostname from command line: $OCSP_HOST"
fi

# EXTRACT CHAIN INFO
# ----------------------------------

if [ -e $CERT.issuer ]; then
    Debug "Using existing chain ($CERT.issuer)"

    # copy .issuer file to temporary chain.pem
    cp $CERT.issuer $TMP/chain.pem &>>$TMP/log

    if [ $? -ne 0 ]; then
        Error 3 "can't copy current chain from $CERT.issuer"
    fi
else
    Debug "Extracting chain from certificates bundle"

    # get EE certificate's fingerprint
    FP_EE="`$OPENSSL_BIN x509 -fingerprint -noout -in $TMP/ee.pem`"

    if [ $? -ne 0 -o -z "$FP_EE" ]; then
        Error 1 "can't obtain EE certificate's fingerprint"
    fi

    Debug "EE certificate's fingerprint: $FP_EE"

    # get BEGIN CERTIFICATE and END CERTIFICATE separators
    PEM_BEGIN_CERT="`head $TMP/ee.pem -n 1`"
    PEM_END_CERT="`tail $TMP/ee.pem -n 1`"

    # get number of certificates in the bundle file
    NUM_OF_CERTS=`cat $CERT | grep -e "$PEM_BEGIN_CERT" | wc -l`

    if [ $NUM_OF_CERTS -le 1 ]; then
        Error 3 "can't obtain the number of certificates in the chain"
    fi

    Debug "$NUM_OF_CERTS certificates found in the bundle"

    # save each certificate in the bundle into $TMP/chain-X.pem 
    cat $CERT | \
        sed -n -e "/$PEM_BEGIN_CERT/,/$PEM_END_CERT/p" | \
        awk "/$PEM_BEGIN_CERT/{x=\"$TMP/chain-\" ++i \".pem\";}{print > x;}" &>>$TMP/log

    if [ $? -ne 0 ]; then
        Error 3 "can't extract certificates from bundle"
    fi

    # for each certificate that is extracted from the bundle check if 
    # it's the EE certificate, otherwise uses it to build the chain file
    for c in `seq 1 $NUM_OF_CERTS`;
    do
        # check fingerprint of current and EE certificates
        FP="`$OPENSSL_BIN x509 -fingerprint -noout -in $TMP/chain-$c.pem`"
        if [ $? -ne 0 -o -z "$FP" ]; then
            Error 1 "can't obtain the fingerprint of the certificate n. $c in the bundle"
        else
            if [ ! "$FP" == "$FP_EE" ]; then
                Debug "Bundle certificate n. $c fingerprint: $FP - it's part of the chain"

                # current certificate is not the same as the EE; append to the chain
                cat $TMP/chain-$c.pem >> $TMP/chain.pem
            else
                Debug "Bundle certificate n. $c fingerprint: $FP - EE certificate"
            fi
        fi
    done
fi

# check if the EE certificate validates against the chain
$OPENSSL_BIN verify $PARTIAL_CHAIN -CAfile $TMP/chain.pem $TMP/ee.pem &>>$TMP/log

if [ $? -ne 0 ]; then
    if [ -e $CERT.issuer ]; then
        Error 1 "can't validate the EE certificate against the existing chain; if it has been changed recently consider removing the current $CERT.issuer file and let this script to figure out a new one"
    else
        Error 1 "can't validate the EE certificate against the extracted chain"
    fi
fi

# OCSP
# ----------------------------------

# query the OCSP server and save its response

$OPENSSL_BIN version | grep "OpenSSL 1.0" &>/dev/null
if [ $? -eq 0 ]; then
    # OpenSSL 1.0.x

    $OPENSSL_BIN ocsp $PARTIAL_CHAIN -issuer $TMP/chain.pem -cert $TMP/ee.pem \
        -respout $TMP/ocsp.der -noverify \
        -no_nonce -url $OCSP_URL -header "Host" "$OCSP_HOST" &>>$TMP/log
else
    $OPENSSL_BIN ocsp $PARTIAL_CHAIN -issuer $TMP/chain.pem -cert $TMP/ee.pem \
        -respout $TMP/ocsp.der -noverify \
        -no_nonce -url $OCSP_URL -header "Host=$OCSP_HOST" &>>$TMP/log
fi

if [ $? -ne 0 ]; then
    Error 1 "can't receive the OCSP server response"
fi

# process the OCSP response
VERIFYOPT=""
if [ $VERIFY -eq 0 ]; then
    VERIFYOPT="-noverify"
fi
if [ -z "$VAFILE" ]; then
    $OPENSSL_BIN ocsp $PARTIAL_CHAIN $VERIFYOPT -issuer $TMP/chain.pem -cert $TMP/ee.pem \
        -respin $TMP/ocsp.der -no_nonce -CAfile $TMP/chain.pem \
        -out $TMP/ocsp.txt &>>$TMP/ocsp-verify.txt
else
    $OPENSSL_BIN ocsp $PARTIAL_CHAIN $VERIFYOPT -issuer $TMP/chain.pem -cert $TMP/ee.pem \
        -respin $TMP/ocsp.der -no_nonce -CAfile $TMP/chain.pem \
        -VAfile $VAFILE \
        -out $TMP/ocsp.txt &>>$TMP/ocsp-verify.txt
fi

if [ $? -ne 0 ]; then
    Error 1 "can't receive OCSP response"
fi

if [ $VERIFY -eq 1 ]; then
    Debug "OCSP response verification results: `cat $TMP/ocsp-verify.txt`"

    cat $TMP/ocsp-verify.txt | grep "Response verify OK" &>>$TMP/log

    if [ $? -ne 0 ]; then
        grep "signer certificate not found" $TMP/ocsp-verify.txt &>/dev/null

        if [ $? -eq 0 ]; then
            Error 4 "OCSP response verification failure: signer certificate not found; try with '--VAfile -' or '--VAfile OCSP-response-signing-certificate-file' arguments"
        else
            Error 4 "OCSP response verification failure."
        fi
    fi
fi

Debug "OCSP response: `cat $TMP/ocsp.txt`"

if [ $GOOD_ONLY -eq 1 ]; then
    cat $TMP/ocsp.txt | head -n 1 | grep ": good" &>>$TMP/log

    if [ $? -ne 0 ]; then
        Error 4 "OCSP response, certificate status not good"
    fi
fi

# UPDATE HAPROXY
# ----------------------------------

# Status:
#   - $TMP/ocsp.der contains the OCSP response, DER format
#   - $TMP/ocsp.txt contains the textual OCSP response as produced
#       by openssl
#   - the OCSP response has been verified against the chain or 
#       the --VAfile

if [ $DEBUG -eq 0 ]; then
    # update .ocsp and .issuer files

    cp $TMP/ocsp.der $CERT.ocsp &>>$TMP/log

    if [ $? -ne 0 ]; then
        Error 5 "can't update $CERT.ocsp file"
    fi

    if [ ! -e $CERT.issuer ]; then
        cp $TMP/chain.pem $CERT.issuer &>>$TMP/log

        if [ $? -ne 0 ]; then
            Error 5 "can't update $CERT.issuer file"
        fi
    fi

    if [ $SKIP_UPDATE -eq 0 ]; then
        if [ $ISNEW -eq 1 ]; then
            # no .ocsp file found, maybe it's an initial run
            Debug "Reloading haproxy."

            service haproxy reload

            if [ $? -ne 0 ]; then
                Error 5 "can't reload haproxy with 'service haproxy reload'"
            fi
        else
            # update haproxy via local UNIX socket
            Debug "Updating haproxy."

            echo "set ssl ocsp-response `base64 -w 0 $TMP/ocsp.der`" | $SOCAT_BIN stdio $HAPROXY_ADMIN_SOCKET &>>$TMP/log

            if [ $? -ne 0 ]; then
                Error 5 "can't update haproxy ssl ocsp-response using $HAPROXY_ADMIN_SOCKET socket"
            fi
        fi
    else
        Debug "Not notifying haproxy because skip-update is set."
    fi

else
    Debug "Debug mode: haproxy update skipped."
fi

# remove temporary files and quit with success
Quit 0

# vim: set tabstop=4 shiftwidth=4 expandtab: