#! /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-overwrite] [-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
# Unless set via env var; set default Diffie-Hellman bit size as 1024
DH_BITS=${DH_BITS:-2048}
DH_BITS_REC=2048 # minimum recommended DH bit size
GEN_DH=false
DH_ONLY=false

clean_up() { # Perform pre-exit housekeeping
  rm -f $TMPFILE $TMPOUT
  return
}

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-overwrite] 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-overwrite] [-b|--dh-bits DH_BITS]
  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-overwrite] [-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-overwrite] [-r|--csr] FQDN .. [FQDN]
  Generate an optional certificate signing request for the list of FQDNs.

Usage: $PROGNAME -p|--dh-params-only [-b|--dh-bits DH_BITS]
  Generate just a new dhparam.pem file of bit size DH_BITS. 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
      -b, --dh-bits DH_BITS   Generate a Diffie-Hellman parameters file; bit
                                size: DH_BITS (can also be set via env var)
                                /etc/ssl/private/dhparams.pem
                                valid options: 1024 | 2048 | 4096
                                default: $DH_BITS
                                recommended: $DH_BITS_REC
                                Note that as of v1.4 an audited dh_params is
                                provided (as per RFC7919)
      -p, --dh-params-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-overwrite   Overwrite 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

  sed -i "s#@HostName@#\"$commonName\"#" $TMPFILE
}

expiry_days() {
  [ -z "$1" ] && error_exit "Expiry date not set"

  number="${1//[a-z]}"
  [[ "$number" =~ ^[0-9]+$ ]] || error_exit "$number in Expiry is not a valid integer"
  ([ "$number" -lt 1 ] || [ "$number" -gt 10957 ]) \
    && error_exit "$number in Expiry is out of bounds. Must be between 1 and 10957"

  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 "$interval in Expiry not supported; use only d|m|y (days|months|years)."
  esac

  [ ! "$days" -lt 1 ] && [ ! "$days" -gt 10957 ] \
    || error_exit "Expiry must be between 1 day (1d) and 30 years (10957d)"
}

# 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" ;;
    -b | --dh-bits)           shift; DH_BITS="$1" ; GEN_DH=true ;;
    -p | --dh-params-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)   OVERWRITE=true ;;
    -* | --*)                 usage; error_exit "Unknown option $1" ;;
    *)                        args+="$1 " ;;
  esac
  shift
done

if [[ $DH_ONLY == true ]]; then
    warning "--dh-params-only switch used, no cert or key files will be generated."
fi
if [[ $DH_BITS -ne 1024 ]] && [[ $DH_BITS -ne 2048 ]] \
                                    && [[ $DH_BITS -ne 4096 ]]; then
    error_exit "DH_BITS must be one of 1024, 2048 or 4096."
elif [[ $DH_BITS -eq 1024 ]]; then
    msg="DH_BITS of 1024 is not recommended and may leave https connections"
    msg="$msg with your webserver vulnerable to attack. Unless you need to"
    msg="$msg support really old clients (e.g. Windows XP, Internet Explorer"
    msg="$msg <11, etc) use 2048 or 4096 bits."
    warning "$msg"
fi

expiry_days $EXPIRY

# Exit if OpenSSL is not available
which openssl > /dev/null || error_exit "OpenSSL is not installed."

# 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 << _EOF_ >&2

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
