#! /bin/bash -e
# ---------------------------------------------------------------------------
# turnkey-make-ssl-cert - "Make server cert for TurnKey GNU/Linux appliance"

# Copyright 2014,2015, John Carver <dude4linux@gmail.com>
  
  # 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 (http://www.gnu.org/licenses/) for
  # more details.

# Usage: turnkey-make-ssl-cert [-h|--help] [-o|--out file] [-d|--default] [-w|--wild] [-t|--template file] [-i|--ip] [-v|--verbose] [-f|--force] [-r|--csr] FQDN .. [FQDN]

# Purpose:

  # - Make default certificate for TurnKey GNU/Linux appliance [-d|--default]
  #
  #     cat \
  #       /usr/local/share/ca-certificates/cert.crt \
  #       /etc/ssl/private/cert.key \
  #       /etc/ssl/private/dhparams.pem > /etc/ssl/private/cert.pem
  #
  # - Make server certificates for Apache/Nginx virtual hosts
  # - Make wildcard certificate for one or more domains [-w|--wild]
  # - Use sha256 and aes128 for additional security "Bulletproof SSL and TLS"
  # - Use Subject Alternate Names (SAN) for host and domain names
  # - Optionally generate a certificate signing request (csr)
  # - Optionally include ip addresses in SAN list [-i|--ip]
  # - Store keys in /etc/ssl/private for security
  # - Follow Debian conventions and best practices

# Revision history:
# 2014-08-28 Created by new-script ver. 3.0
# 2014-09-12 Released tkl-make-ssl-cert ver. 1.0
# 2014-10-02 Released tkl-make-ssl-cert ver. 1.1
# 2015-08-06 Released turnkey-make-ssl-cert ver. 1.2
# 2017-03-06 Released turnkey-make-ssl-cert ver. 1.3
# ---------------------------------------------------------------------------

[[ $DEBUG != y ]] || set -x

PROGNAME=${0##*/}
VERSION="1.3"

# use extended globbing
shopt -s extglob

# set defaults
CERT_DIR="/usr/local/share/ca-certificates"
KEY_DIR="/etc/ssl/private"
TEMPLATE="/etc/ssl/turnkey.cnf"
DEFAULT=false
LE=false
IP=false
OUT=false
VERBOSE=false
REQUEST=false
WILD=false
OVERWRITE=false
EXPIRY=10y
GEN_DH=false
DH_ONLY=false

clean_up() { # Perform pre-exit housekeeping
    [[ $DEBUG == y ]] || rm -f $TMPFILE $TMPOUT
}

info() { echo "INFO [$PROGNAME]: $@"; }
warning() { echo "WARNING [$PROGNAME]: $@" >&2 ; }

error_exit() {
    echo -e ERROR [${PROGNAME}]: ${1:-"Unknown Error"} >&2
    if [[ $2 ]] && [[ -f $2 ]]; then
        cat $2 >&2
    fi
    clean_up
    exit 1
}

graceful_exit() {
    clean_up
    exit
}

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 "Terminating on unknown signal";;
    esac
}

usage() {

cat <<EOF
Usage: $PROGNAME [-o|--out file] [-t|--template file] [-i|--ip] [-v|--verbose] [-f|--force] FQDN .. [FQDN]
  Generate a certificate/key pair using the list of FQDNs.

Usage: $PROGNAME [-d|--default] [-t|--template file] [-i|--ip] [-v|--verbose] [-f|--force]
  Generate the default cert.crt, cert.key, dhparam.pem file and default cert.pem file; using the hostname.

Usage: $PROGNAME [-o|--out file] [-t|--template file] [-i|--ip] [-v|--verbose] [-f|--force] [-w|--wild] domainName .. [domainName]
  Generate a wildcard certificate for the list of domains.

Usage: $PROGNAME [-d|--default] [-t|--template file] [-i|--ip] [-v|--verbose] [-f|--force] [-r|--csr] FQDN .. [FQDN]
  Generate an optional certificate signing request for the list of FQDNs.

Usage: $PROGNAME -p|--dh-only
  Generate just a new dhparam.pem file. Also rebuilds the default cerm.pem file with update DH params.

Usage: $PROGNAME [-h|--help]
  Display the help message and exit.

EOF

  return
}

help_message() {

cat <<EOF

$PROGNAME ver. $VERSION
"Make server cert for TurnKey GNU/Linux appliance"

$(usage)

      Options:
      -h, --help              Display this help message and exit
      -o, --out [/PATH/]FILE  Write certificate to alternate location
      -d, --default           Generate default cert, key & dhparams files
                                plus a combined file
                                /etc/ssl/private/cert.pem
      -e, --expiry            Set certificate expiry date
                                default: 10y
      -p, --dh-only           Generate a Diffie-Hellman parameters file only
                                (Also rebuilds default cert.pem file)
      -r, --csr               Generate a certificate signing request
      -w, --wild              Generate wildcard certificate
      -t, --template FILE     Use alternate template file
                                default: /etc/ssl/turnkey.cnf
      -i, --ip                Optionally include host ip addresses
      -v, --verbose           Display generated certificate
      -f, --force             Force overwrite of existing certificate files

      NOTE: You must be the superuser to run this script.

EOF

return
}

add_san() {
    if [[ ! "$sans" =~ " $1 " ]]; then          # skip duplicates
        echo "DNS.$((n++)) = $1" >> $TMPFILE    # add subject alternate name
        sans+="$1 "
    fi
}

add_ip() {
    echo "IP.$((i++)) = $1" >> $TMPFILE
}

create_temporary_cnf() {
    n=1; i=1; sans=" "

    # remove alt_names from template
    sed -e '/^\[\s*alt_names\s*\]/q' $TEMPLATE > $TMPFILE

    if [[ $WILD == true ]]; then
        [[ "$args" == "" ]] && args="$(hostname -Ad)"
        for domain in $args; do
            commonName=${commonName:-$domain};    # use first match
            add_san "$domain";                    # domain
            add_san "*.$domain";                  # wildcard
        done
    else
        [[ "$args" == "" ]] && args="$(hostname -A)"
        [[ "$args" == "" ]] && args="$(hostname)"
        for fqdn in $args; do
            commonName=${commonName:-$fqdn}      # use first match

            add_san "$fqdn"                      # fqdn
            add_san "${fqdn#*.}"                 # domain
            add_san "${fqdn%%.*}"                # host
        done
    fi

    if [[ $IP == true ]]; then
        for addr in $(hostname -I) '127.0.0.1'; do
            add_san "$addr"; add_ip "$addr"
        done
    fi
    # truncate common name if it's too long for openssl
    if [[ "${#commonName}" -gt 18 ]]; then
        commonName="${commonName:0:18}"
    fi
    sed -i "s#@HostName@#\"$commonName\"#" $TMPFILE
}

expiry_days() {
    num="${1//[dmy]}"
    case $num in
        '')
            error_exit "Expiry date not set"
            ;;
        *[!0-9]*)
            error_exit "Expiry can only be digits (with d|m|y suffix) got: $1"
            ;;
    esac
    interval="${1//[0-9]}"
    case "$interval" in
        y)  days=$((($(date -d "+$number year" +%s) - $(date +%s))/86400));;
        m)  days=$((($(date -d "+$number month" +%s) - $(date +%s))/86400));;
        d)  days=$((($(date -d "+$number day" +%s) - $(date +%s))/86400));;
        *)  error_exit "Expiry $interval not recognised.";;
    esac
    if [[ "$days" -lt 1 ]] || [[ "$days" -gt 10957 ]]; then
        error_exit "Expiry must be between 1 day (1d) and 30 years (10957d)"
    fi
}

# Trap signals
trap "signal_exit TERM" TERM HUP
trap "signal_exit INT"  INT

# Parse command-line
while [[ -n $1 ]]; do
    case $1 in
        -h|--help)      help_message; graceful_exit;;
        -o|--out)       shift; CERT="$1"; OUT=true;;
        -d|--default)   DEFAULT=true; GEN_DH=true;;
        -e|--expiry)    shift; EXPIRY="$1";;
        -p|--dh-only)   DH_ONLY=true; GEN_DH=true;;
        -r|--csr)       REQUEST=true;;
        -w|--wild)      WILD=true;;
        -t|--template)  shift; TEMPLATE="$1";;
        -i|--ip)        IP=true;;
        -v|--verbose)   VERBOSE=true;;
        -f|--force)     OVERWRITE=true;;
        -*|--*)         usage; error_exit "Unknown option $1";;
        *)              args+="$1 ";;
    esac
    shift
done

if [[ $DH_ONLY == true ]]; then
    warning "--dh-only switch used, no cert or key files will be generated."
fi

expiry_days $EXPIRY

# Check for root UID
if [[ $(id -u) != 0 ]]; then
    error_exit "You must be the superuser to run this script."
fi

# Template file must exist
if [[ ! -f $TEMPLATE ]]; then
    error_exit "Could not open template file: $TEMPLATE"
fi

## Main logic ##
TMPFILE="$(mktemp)" || error_exit "Can't create temporary file"
TMPOUT="$(mktemp)"  || error_exit "Can't create temporary file"

create_temporary_cnf

# assign file names
DHP="$KEY_DIR/dhparams.pem"
if [[ $DEFAULT == true ]] || [[ $DH_ONLY == true ]]; then
    OUT=false    # ignore output file
    CERT="$CERT_DIR/cert.crt"
    KEY="$KEY_DIR/cert.key"
    CSR="$KEY_DIR/cert.csr"
elif [[ $OUT == false ]]; then
    CERT="$CERT_DIR/$commonName.crt"
    KEY="$KEY_DIR/$commonName.key"
    CSR="$KEY_DIR/$commonName.csr"
else
    # ensure user input cert name has extension pem or crt
    shopt -s nocasematch
    [[ ${CERT##*.} =~ (pem|crt) ]] || CERT+=".crt"
    shopt -u nocasematch
    KEY="${CERT%.*}.key"
    CSR="${CERT%.*}.csr"
fi

# don't overwrite existing key and/or cert files without permission
if [[ -f $CERT ]] || [[ -f $KEY ]]; then
    # except when only generating dhparams
    if [[ $OVERWRITE == false ]] && [[ $DH_ONLY == false ]]; then
        error_exit "Output file(s) already exists: $CERT &/or $KEY"
    fi
fi

# don't generate dhparams only unless cert & key both exist
if [[ ! -f $CERT ]] && [[ ! -f $KEY ]] && [[ $DH_ONLY == true ]]; then
    msg="One of $CERT & $KEY files is missing, won't be able to rebuild
         combined cert.pem file. Please rerun with different options."
    error_exit "$msg"
fi

# create the key and certificate.
if [[ $DH_ONLY == false ]]; then
    info "Generating certificate and key files."
    if ! openssl req -sha256 -config $TMPFILE -new -x509 -days $days \
            -nodes -out $CERT -keyout $KEY > $TMPOUT 2>&1; then
        error_exit "Could not create certificate. Openssl output was:" $TMPOUT
    fi
fi

# gen dhparams.
if [[ "$GEN_DH" == "true" ]]; then
    msg="Generating Diffie-Hellman parameters file - using predifined"
    msg="$msg parameters as per RFC7919."
    info "$msg"
    if ! openssl genpkey -genparam -algorithm DH \
                -pkeyopt dh_param:ffdhe$DH_BITS -out $DHP \
                -outform PEM > $TMPOUT 2>&1; then
        msg="Diffie-Hellman parameter generation failed.  OpenSSL output: "
        error_exit "$msg" $TMPOUT
    fi
fi

# set file permissions
chmod 400 "$KEY"
chmod 644 "$CERT"
chmod 400 "$DHP"

# create default cert.pem file, including dhparams
if [[ $DEFAULT == true ]] || [[ $DH_ONLY == true ]]; then
    cat "$CERT" "$KEY" "$DHP" > "$KEY_DIR/cert.pem"
    chmod 400 "$KEY_DIR/cert.pem"
fi

# create the certificate signing request.
if [[ $REQUEST == true ]]; then
    # warn if organizationName is "TurnKey GNU/Linux"
    if [[ $(grep '^organizationName\s*=\s*"TurnKey GNU/Linux"' $TEMPLATE) ]]; then

cat >&2 <<EOF

WARNING [${PROGNAME}]: organizationName = "TurnKey GNU/Linux"
    Unless you work for TurnKey, you probably want to edit the
    template file, /etc/ssl/turnkey.cnf or create a custom copy
    and use the [-t|--template] option.

EOF

    fi
    # create the certificate signing request.
    if ! openssl req -sha256 -config $TMPFILE -new -key $KEY -out $CSR \
        > $TMPOUT 2>&1; then
        error_exit "Could not create CSR. Openssl output was:" $TMPOUT
    fi
    chmod 600 $CSR
fi

# rehash symlinks in /etc/ssl/certs
if [[ $OUT == false ]]; then
    update-ca-certificates
fi

# handle verbose
if [[ $VERBOSE == true ]]; then
    # display the certificate
    openssl x509 -noout -text -in $CERT
    if [[ $REQUEST == true ]]; then
        # display the certificate signing request.
        openssl req -noout -text -in $CSR
    fi
fi

clean_up

graceful_exit
