#!/usr/bin/env bash

# Unofficial Bash strict mode
set \
  -o errexit \
  -o errtrace \
  -o noglob \
  -o nounset \
  -o pipefail
IFS=$'\n\t'
shopt -s inherit_errexit

exit_with_error() {
  echo "${@}" >&2
  exit 1
}

check_for_dependencies() {
  if ((BASH_VERSINFO[0] == 4 && \
    BASH_VERSINFO[1] < 3 || \
    BASH_VERSINFO[0] < 4)); then
    exit_with_error \
      error:$'\t\t'"${0##*/} requires Bash 4.3+."
  fi

  if ! { command -v openssl >&- &&
    [[ $(openssl version) =~ ^OpenSSL\ ([[:digit:]]+)\.([[:digit:]]+) ]] &&
    ((BASH_REMATCH[1] == 1 && \
    BASH_REMATCH[2] >= 1 || \
    BASH_REMATCH[1] > 1)); }; then
    # shellcheck disable=2016
    exit_with_error \
      error:$'\t\t'"${0##*/} requires OpenSSL 1.1.0+," \
      'but it is not available on $PATH.'
  fi
}

parse_cli_arguments() {
  local -r usage=(
    "USAGE: ${0}"
    "[-c/--certbot-dir DIRECTORY]"
    "[-f/--force-update]"
    "[-h/--help]"
    "[-n/--cert-name NAME[,NAME...] [-u/--ocsp-responder URL]]"
    "[-o/--output-dir DIRECTORY]"
    "[-q/--quiet]"
    "[-v/--verbose]"
    "[-w/--no-reload-webserver]"
  )

  declare -gl ERROR_ENCOUNTERED

  declare -gi VERBOSITY=1
  local -r verbosity_error=(
    "error: -q/--quiet cannot be specified in conjunction with -v/--verbose."
  )

  while ((${#} > 0)); do
    local parameter=${1}

    case ${parameter} in
      -[^-]?*)
        set -- "-${parameter:1:1}" "-${parameter:2}" "${@:2}"
        ;;
      -c | --certbot-dir | --certbot-dir=?*)
        if [[ -v CERTBOT_DIR ]]; then
          exit_with_error "${usage[@]}"
        fi

        if [[ ${parameter} =~ --certbot-dir=(.+) ]]; then
          CERTBOT_DIR=${BASH_REMATCH[1]}
        else
          if [[ -n ${2:-} ]]; then
            CERTBOT_DIR=${2}
            shift
          else
            exit_with_error "${usage[@]}"
          fi
        fi

        CERTBOT_DIR=$(
          realpath \
            --canonicalize-missing \
            --relative-base . \
            -- "${CERTBOT_DIR}"
          echo x
        )
        CERTBOT_DIR=${CERTBOT_DIR%??}
        shift
        ;;
      -f | --force-update)
        if [[ ! -v FORCE_UPDATE ]]; then
          declare -glr FORCE_UPDATE=true
        fi
        shift
        ;;
      -h | --help)
        echo >&2 "${usage[@]}"
        exit
        ;;
      -n | --cert-name | --cert-name=?*)
        if [[ ${parameter} =~ --cert-name=(.+) ]]; then
          local cert_lineages_value=${BASH_REMATCH[1]}
          shift
        else
          if [[ -n ${2:-} ]]; then
            local cert_lineages_value=${2}
            shift 2
          else
            exit_with_error "${usage[@]}"
          fi
        fi

        # Loop over any lineages passed in the same value of --cert-name.
        OLDIFS=${IFS}
        IFS=,
        declare -Ag CERT_LINEAGES
        # Check if a hardcoded OCSP responder was specified for this set of
        # lineages.
        case ${1:-} in
          -u | --ocsp-responder)
            if [[ -n ${2:-} ]]; then
              for lineage_name in ${cert_lineages_value}; do
                CERT_LINEAGES["${lineage_name}"]=${2}
              done
              shift
            else
              exit_with_error "${usage[@]}"
            fi
            shift
            ;;
          --ocsp-responder=?*)
            [[ ${1} =~ --ocsp-responder=(.+) ]]
            for lineage_name in ${cert_lineages_value}; do
              CERT_LINEAGES["${lineage_name}"]=${BASH_REMATCH[1]}
            done
            shift
            ;;
          *)
            # If no OCSP responder was specified, just save the lineage
            # name as the key, with an empty value.
            for lineage_name in ${cert_lineages_value}; do
              CERT_LINEAGES["${lineage_name}"]=
            done
            ;;
        esac
        unset lineage_name cert_lineages_value
        IFS=${OLDIFS}
        ;;
      -o | --output-dir | --output-dir=?*)
        if [[ -v OUTPUT_DIR ]]; then
          exit_with_error "${usage[@]}"
        fi

        if [[ ${parameter} =~ --output-dir=(.+) ]]; then
          OUTPUT_DIR=${BASH_REMATCH[1]}
        else
          if [[ -n ${2:-} ]]; then
            OUTPUT_DIR=${2}
            shift
          else
            exit_with_error "${usage[@]}"
          fi
        fi

        OUTPUT_DIR=$(
          realpath \
            --canonicalize-missing \
            --relative-base . \
            -- "${OUTPUT_DIR}"
          echo x
        )
        OUTPUT_DIR=${OUTPUT_DIR%??}
        shift
        ;;
      -q | --quiet)
        if ((VERBOSITY != 1)); then
          exit_with_error "${verbosity_error[@]}"
        else
          readonly VERBOSITY=0
          shift
        fi
        ;;
      -v | --verbose)
        if ((VERBOSITY == 0)); then
          exit_with_error "${verbosity_error[@]}"
        else
          VERBOSITY+=1
          shift
        fi
        ;;
      -w | --no-reload-webserver)
        if [[ ! -v RELOAD_WEBSERVER ]]; then
          declare -glr RELOAD_WEBSERVER=false
        fi
        shift
        ;;
      *)
        exit_with_error "${usage[@]}"
        ;;
    esac
  done

  # When not parsed, the stdout and/or stderr output of all external commands
  # we call in the script is redirected to file descriptor 3.  Depending on the
  # desired verbosity, we redirect this file descriptor to either stderr or to
  # /dev/null.
  if ((VERBOSITY >= 2)); then
    exec 3>&2
  else
    exec 3>/dev/null
  fi
}

# Set output directory if necessary and check if it's writeable
prepare_output_dir() {
  if [[ -v OUTPUT_DIR ]]; then
    if [[ ! -e ${OUTPUT_DIR} ]]; then
      # Don't yet fail if it's not possible to create the directory, so we can
      # exit with a custom error down below
      mkdir \
        --parents \
        -- "${OUTPUT_DIR}" || true
    fi
  else
    readonly OUTPUT_DIR=.
  fi

  if [[ ! -w ${OUTPUT_DIR} ]]; then
    exit_with_error \
      error:$'\t\t'"no write access to output directory (\"${OUTPUT_DIR}\")"
  fi
}

start_in_correct_mode() {
  # Create temporary directory to store OCSP staple file,
  # before having checked the certificate status in the response
  local temp_output_dir
  temp_output_dir=$(mktemp --directory)
  readonly temp_output_dir
  trap "rm -r -- ""${temp_output_dir}" EXIT

  declare -A lineages_processed

  # These two environment variables are set if this script is invoked by Certbot
  if [[ ! -v RENEWED_DOMAINS || ! -v RENEWED_LINEAGE ]]; then
    run_standalone
  else
    run_as_deploy_hook
  fi

  print_and_handle_result
}

# Run in "check one or all certificate lineage(s) managed by Certbot" mode
# $1 - Path to temporary output directory
run_standalone() {
  readonly CERTBOT_DIR=${CERTBOT_DIR:-/etc/letsencrypt}

  if [[ ! -r ${CERTBOT_DIR} || (-d ${CERTBOT_DIR}/live && ! -r ${CERTBOT_DIR}/live) ]]; then
    exit_with_error \
      error:$'\t\t'"can't access ${CERTBOT_DIR}/live"
  fi

  # Check specific lineage if passed on CLI,
  # or otherwise all lineages in Certbot's dir
  if [[ -v CERT_LINEAGES[@] ]]; then
    for lineage_name in "${!CERT_LINEAGES[@]}"; do
      if [[ -r ${CERTBOT_DIR}/live/${lineage_name} ]]; then
        fetch_ocsp_response \
          "--standalone" \
          "${temp_output_dir}" \
          "${lineage_name}" \
          "${CERT_LINEAGES["${lineage_name}"]}"
      else
        exit_with_error \
          "error:"$'\t\t'"can't access ${CERTBOT_DIR}/live/${lineage_name}"
      fi
    done
  else
    set +f
    shopt -s nullglob
    for lineage_dir in "${CERTBOT_DIR}"/live/*; do
      set -f

      # Skip non-directories, like Certbot's README file
      [[ -d ${lineage_dir} ]] || continue

      fetch_ocsp_response \
        "--standalone" "${temp_output_dir}" "${lineage_dir##*/}"
    done
    unset lineage_dir
  fi
}

# Run in deploy-hook mode, only processing the passed lineage
# $1 - Path to temporary output directory
run_as_deploy_hook() {
  if [[ -v CERTBOT_DIR ]]; then
    # The directory is already inferred from the environment variable that
    # Certbot passes
    exit_with_error \
      error:$'\t\t'"-c/--certbot-dir cannot be passed" \
      "when run as Certbot hook"
  fi

  if [[ -v FORCE_UPDATE ]]; then
    # When run as deploy hook the behavior of this flag is used by default.
    # Therefore passing this flag would not have any effect.
    exit_with_error \
      error:$'\t\t'"-f/--force-update cannot be passed" \
      "when run as Certbot hook"
  fi

  if [[ -v CERT_LINEAGES[@] ]]; then
    # The certificate lineage is already inferred from the environment
    # variable that Certbot passes
    exit_with_error \
      error:$'\t\t'"-n/--cert-name cannot be passed when run as Certbot hook"
  fi

  fetch_ocsp_response \
    --deploy_hook "${temp_output_dir}" "${RENEWED_LINEAGE##*/}"
}

# Check if it's necessary to fetch a new OCSP response
check_for_existing_ocsp_staple_file() {
  [[ -f ${OUTPUT_DIR}/${lineage_name}.der ]] || return 1

  # Validate and verify the existing local OCSP staple file
  local existing_ocsp_response
  set +e
  existing_ocsp_response=$(openssl ocsp \
    -no_nonce \
    -issuer "${lineage_dir}/chain.pem" \
    -cert "${lineage_dir}/cert.pem" \
    -verify_other "${lineage_dir}/chain.pem" \
    -respin "${OUTPUT_DIR}/${lineage_name}.der" 2>&3)
  local -ir existing_ocsp_response_rc=${?}
  set -e
  readonly existing_ocsp_response

  ((existing_ocsp_response_rc == 0)) || return 1

  for existing_ocsp_response_line in ${existing_ocsp_response}; do
    if [[ ${existing_ocsp_response_line} =~ ^[[:blank:]]*"This Update: "(.+)$ ]]; then
      local -r this_update=${BASH_REMATCH[1]}
    elif [[ ${existing_ocsp_response_line} =~ ^[[:blank:]]*"Next Update: "(.+)$ ]]; then
      local -r next_update=${BASH_REMATCH[1]}
    fi
  done
  [[ -n ${this_update:-} && -n ${next_update:-} ]] || return 1

  # Only continue fetching OCSP response if existing response expires within
  # half of its lifetime.
  local -ri response_lifetime_in_seconds=$((\
    $(date +%s --date "${next_update}") - $(date +%s --date "${this_update}")))
  (($(date +%s) < \
  $(date +%s --date "${this_update}") + response_lifetime_in_seconds / 2)) || return 1
}

# Generate file used by ssl_stapling_file in nginx config of websites
# $1 - Whether to run as a deploy hook for Certbot, or standalone
# $2 - Path to temporary output directory
# $3 - Name of certificate lineage
# $4 - OCSP endpoint (if specified on command line)
fetch_ocsp_response() {
  local -r temp_output_dir=${2}
  local -r lineage_name=${3}
  case ${1} in
    --standalone)
      local -r lineage_dir=${CERTBOT_DIR}/live/${lineage_name}

      if [[ ${FORCE_UPDATE:-} != true ]] &&
        check_for_existing_ocsp_staple_file; then
        lineages_processed["${lineage_name}"]="not updated"$'\t'"valid staple file on disk"
        return
      fi
      ;;
    --deploy_hook)
      local -r lineage_dir=${RENEWED_LINEAGE}
      ;;
    *)
      return 1
      ;;
  esac
  shift 3

  # Verify that the leaf certificate is still valid. If the certificate is
  # expired, we don't have to request a (new) OCSP response.
  local cert_expiry_output
  set +e
  cert_expiry_output=$(openssl x509 \
    -in "${lineage_dir}/cert.pem" \
    -checkend 0 \
    -noout 2>&3)
  local -ri cert_expiry_rc=${?}
  set -e
  if ((cert_expiry_rc != 0)); then
    ERROR_ENCOUNTERED=true
    lineages_processed["${lineage_name}"]="not updated"
    if [[ ${cert_expiry_output} == "Certificate will expire" ]]; then
      lineages_processed["${lineage_name}"]+=$'\t'"leaf certificate expired"
    fi
    return
  fi

  local ocsp_endpoint
  if [[ -n ${1-} ]]; then
    ocsp_endpoint=${1}
  else
    ocsp_endpoint=$(openssl x509 \
      -noout \
      -ocsp_uri \
      -in "${lineage_dir}/cert.pem" \
      2>&3)
  fi

  # Request, verify and temporarily save the actual OCSP response,
  # and check whether the certificate status is "good"
  local ocsp_call_output
  set +e
  ocsp_call_output=$(openssl ocsp \
    -no_nonce \
    -url "${ocsp_endpoint}" \
    -issuer "${lineage_dir}/chain.pem" \
    -cert "${lineage_dir}/cert.pem" \
    -verify_other "${lineage_dir}/chain.pem" \
    -respout "${temp_output_dir}/${lineage_name}.der" 2>&3)
  local -ir ocsp_call_rc=${?}
  set -e
  readonly ocsp_call_output=${ocsp_call_output#${lineage_dir}/cert.pem: }
  local -r cert_status=${ocsp_call_output%%$'\n'*}

  if [[ ${ocsp_call_rc} != 0 || ${cert_status} != good ]]; then
    ERROR_ENCOUNTERED=true

    lineages_processed["${lineage_name}"]="not updated"
    if ((VERBOSITY >= 2)); then
      lineages_processed["${lineage_name}"]+=$'\t'"${ocsp_call_output//[[:space:]]/ }"
    else
      lineages_processed["${lineage_name}"]+=$'\t'"${cert_status}"
    fi

    return
  fi

  # If arrived here status was good, so move OCSP staple file to definitive
  # folder
  mv "${temp_output_dir}/${lineage_name}.der" "${OUTPUT_DIR}/"

  lineages_processed["${lineage_name}"]=updated
}

print_and_handle_result() {
  local -r header=LINEAGE$'\t'RESULT$'\t'REASON

  for lineage_name in "${!lineages_processed[@]}"; do
    local lineages_processed_formatted+=$'\n'"${lineage_name}"$'\t'"${lineages_processed["${lineage_name}"]}"
  done
  unset lineage_name
  lineages_processed_formatted=$(sort <<<"${lineages_processed_formatted:-}")
  readonly lineages_processed_formatted

  if [[ ${RELOAD_WEBSERVER:-} != false ]]; then
    reload_webserver
  fi

  local -r output=${header}${lineages_processed_formatted:-}${nginx_status-}

  if ((VERBOSITY >= 1)); then
    if command -v column >&-; then
      column -ts$'\t' <<<"${output}"
    else
      # shellcheck disable=2016
      echo >&2 \
        'Install the BSD utility `column` for properly formatted output.'$'\n'
      echo "${output}"
    fi
  fi

  [[ ${ERROR_ENCOUNTERED:-} != true ]]
}

reload_webserver() {
  for lineage_name in "${!lineages_processed[@]}"; do
    if [[ ${lineages_processed["${lineage_name}"]} == updated ]]; then
      if nginx -s reload >&3 2>&1; then
        # The last line includes a leading space, to workaround the lack of the
        # `-n` flag in later versions of `column`.
        local -r nginx_status=$'\n\n \t'"nginx reloaded"
      else
        ERROR_ENCOUNTERED=true
        local -r nginx_status=$'\n\n \t'"nginx not reloaded"$'\t'"unable to reload nginx service, try manually"
      fi
      break
    fi
  done
  unset lineage_name
}

main() {
  check_for_dependencies

  parse_cli_arguments "${@}"

  prepare_output_dir

  start_in_correct_mode
}

main "${@}"