#!/usr/bin/env bash
# Copyright (c) 2022:
#   Darren 'Tadgy' Austin <darren (at) afterdark.org.uk>
# Licensed under the terms of the GNU General Public License version 3.
#
# This file contains the default hook functions for dehydrated - these functions will be used when there is no overriding certificate specific hooks file.
# All but startup_hook and ext_hook can be overridden by a hooks script on a per certificate basis.
#

# Get the mail configuration.
source /etc/mail.conf "dehydrated" || exit 1

# Write a message to syslog, and send a copy via email.
notify() {
    local LEVEL="$1" MESSAGE="$2"
    local FACILITY="cron" TAG="dehydrated"
    local LINE INDENT LOG_PREFIX="${LOG_PREFIX:-Certificate renewal} $LEVEL"

    case "$LEVEL" in
      'error') local PRIORITY="err" ;;
      'warning') local PRIORITY="warn" ;;
      'information') local PRIORITY="info" ;;
    esac

    while read LINE; do
      logger --id="$$" -p "$FACILITY.$PRIORITY" -t "$TAG" <<<"${INDENT:-$LOG_PREFIX:} $LINE"
      INDENT="   "
    done <<<"$MESSAGE"

    mailx "${MAILX_ARGS[@]}" -r "$EMAIL_FROM" -s "$LOG_PREFIX" "${EMAIL_TO[@]}" <<<"$MESSAGE"

    return 0
}


deploy_challenge() {
    local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"

    # This hook is called once for every domain that needs to be
    # validated, including any alternative names you may have listed.
    #
    # Parameters:
    # - DOMAIN
    #   The domain name (CN or subject alternative name) being
    #   validated.
    # - TOKEN_FILENAME
    #   The name of the file containing the token to be served for HTTP
    #   validation. Should be served by your web server as
    #   /.well-known/acme-challenge/${TOKEN_FILENAME}.
    # - TOKEN_VALUE
    #   The token value that needs to be served for validation. For DNS
    #   validation, this is what you want to put in the _acme-challenge
    #   TXT record. For HTTP validation it is the value that is expected
    #   be found in the $TOKEN_FILENAME file.

    # Simple example: Use nsupdate with local named
    # printf 'server 127.0.0.1\nupdate add _acme-challenge.%s 300 IN TXT "%s"\nsend\n' "${DOMAIN}" "${TOKEN_VALUE}" | nsupdate -k /var/run/named/session.key
}


clean_challenge() {
    local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"

    # This hook is called after attempting to validate each domain,
    # whether or not validation was successful. Here you can delete
    # files or DNS records that are no longer needed.
    #
    # The parameters are the same as for deploy_challenge.

    # Simple example: Use nsupdate with local named
    # printf 'server 127.0.0.1\nupdate delete _acme-challenge.%s TXT "%s"\nsend\n' "${DOMAIN}" "${TOKEN_VALUE}" | nsupdate -k /var/run/named/session.key
}


sync_cert() {
    local KEYFILE="${1}" CERTFILE="${2}" FULLCHAINFILE="${3}" CHAINFILE="${4}" REQUESTFILE="${5}"

    # This hook is called after the certificates have been created but before
    # they are symlinked. This allows you to sync the files to disk to prevent
    # creating a symlink to empty files on unexpected system crashes.
    #
    # This hook is not intended to be used for further processing of certificate
    # files, see deploy_cert for that.
    #
    # Parameters:
    # - KEYFILE
    #   The path of the file containing the private key.
    # - CERTFILE
    #   The path of the file containing the signed certificate.
    # - FULLCHAINFILE
    #   The path of the file containing the full certificate chain.
    # - CHAINFILE
    #   The path of the file containing the intermediate certificate(s).
    # - REQUESTFILE
    #   The path of the file containing the certificate signing request.

    # Simple example: sync the files before symlinking them
    # sync "${KEYFILE}" "${CERTFILE}" "${FULLCHAINFILE}" "${CHAINFILE}" "${REQUESTFILE}"
}


deploy_cert() {
    local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"

    # This hook is called once for each certificate that has been
    # produced. Here you might, for instance, copy your new certificates
    # to service-specific locations and reload the service.
    #
    # Parameters:
    # - DOMAIN
    #   The primary domain name, i.e. the certificate common
    #   name (CN).
    # - KEYFILE
    #   The path of the file containing the private key.
    # - CERTFILE
    #   The path of the file containing the signed certificate.
    # - FULLCHAINFILE
    #   The path of the file containing the full certificate chain.
    # - CHAINFILE
    #   The path of the file containing the intermediate certificate(s).
    # - TIMESTAMP
    #   Timestamp when the specified certificate was created.

    # Override the default log line/mail subject prefix.
    local LOG_PREFIX="Certificate deployment"

    # Where the copies of the current certificates/keys should be placed.
    local CERTDIR="/etc/certificates"

    # If any of the files are symlinks, bail out - we don't want to clobber something.
    for FILE in "$CERTDIR/$DOMAIN-"{cert,key,chain,fullchain}.pem; do
      [[ -e "$FILE" ]] && [[ -L "$FILE" ]] && {
        notify "error" "Will not copy to symlink '$FILE' during '$DOMAIN' certificate deployment."
        # Return 0 so that dehydrated doesn't stop - there may be some more certificates to renew.
        return 0
      }
    done

    # The first time through this will create the files readable by root only, but better to err on the side of caution.
    # Subsequent runs will retain whatever permissions were set by the admin after the first run.
    umask 066
    cat "$CERTFILE" >"$CERTDIR/$DOMAIN-cert.pem" && cat "$KEYFILE" >"$CERTDIR/$DOMAIN-key.pem" && \
        cat "$CHAINFILE" >"$CERTDIR/$DOMAIN-chain.pem" && cat "$FULLCHAINFILE" >"$CERTDIR/$DOMAIN-fullchain.pem" || {
      notify "error" "Failed to copy certificates/keys during '$DOMAIN' certificate deployment."
      # Return 0 so that dehydrated doesn't stop - there may be some more certificates to renew.
      return 0
    }

    # Set a marker (used in the exit_hook function) to signal that services should be restarted at the end of deployments.
    touch /run/dehydrated-reload-marker || {
      notify "error" "Failed to create reload marker during '$DOMAIN' certificate deployment."
      # Return 0 so that dehydrated doesn't stop - there may be some more certificates to renew.
      return 0
    }

    # Notify the sysadmin of the sucessful renewal.
    notify "information" "Sucessful renewal and deployment of certificate for '$DOMAIN'."
    return 0
}


deploy_ocsp() {
    local DOMAIN="${1}" OCSPFILE="${2}" TIMESTAMP="${3}"

    # This hook is called once for each updated ocsp stapling file that has
    # been produced. Here you might, for instance, copy your new ocsp stapling
    # files to service-specific locations and reload the service.
    #
    # Parameters:
    # - DOMAIN
    #   The primary domain name, i.e. the certificate common
    #   name (CN).
    # - OCSPFILE
    #   The path of the ocsp stapling file
    # - TIMESTAMP
    #   Timestamp when the specified ocsp stapling file was created.

    # Simple example: Copy file to nginx config
    # cp "${OCSPFILE}" /etc/nginx/ssl/; chown -R nginx: /etc/nginx/ssl
    # systemctl reload nginx
}


unchanged_cert() {
    local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}"

    # This hook is called once for each certificate that is still
    # valid and therefore wasn't reissued.
    #
    # Parameters:
    # - DOMAIN
    #   The primary domain name, i.e. the certificate common
    #   name (CN).
    # - KEYFILE
    #   The path of the file containing the private key.
    # - CERTFILE
    #   The path of the file containing the signed certificate.
    # - FULLCHAINFILE
    #   The path of the file containing the full certificate chain.
    # - CHAINFILE
    #   The path of the file containing the intermediate certificate(s).
}


invalid_challenge() {
    local DOMAIN="${1}" RESPONSE="${2}"

    # This hook is called if the challenge response has failed, so domain
    # owners can be aware and act accordingly.
    #
    # Parameters:
    # - DOMAIN
    #   The primary domain name, i.e. the certificate common
    #   name (CN).
    # - RESPONSE
    #   The response that the verification server returned

    # Notify the sysadmin.
    notify "error" "Validation of '$DOMAIN' failed:"$'\n'"$RESPONSE"

    return 0
}


request_failure() {
    local STATUSCODE="${1}" REASON="${2}" REQTYPE="${3}" HEADERS="${4}"

    # This hook is called when an HTTP request fails (e.g., when the ACME
    # server is busy, returns an error, etc). It will be called upon any
    # response code that does not start with '2'. Useful to alert admins
    # about problems with requests.
    #
    # Parameters:
    # - STATUSCODE
    #   The HTML status code that originated the error.
    # - REASON
    #   The specified reason for the error.
    # - REQTYPE
    #   The kind of request that was made (GET, POST...)

    # Notify the sysadmin.
    notify "error" "HTTP $REQTYPE request failed for '$DOMAIN' with code $STATUSCODE."$'\n'"Reason: $REASON."$'\n'"Headers:"$'\n'"$HEADERS"

    return 0
}


generate_csr() {
    local DOMAIN="${1}" CERTDIR="${2}" ALTNAMES="${3}"

    # This hook is called before any certificate signing operation takes place.
    # It can be used to generate or fetch a certificate signing request with external
    # tools.
    # The output should be just the cerificate signing request formatted as PEM.
    #
    # Parameters:
    # - DOMAIN
    #   The primary domain as specified in domains.txt. This does not need to
    #   match with the domains in the CSR, it's basically just the directory name.
    # - CERTDIR
    #   Certificate output directory for this particular certificate. Can be used
    #   for storing additional files.
    # - ALTNAMES
    #   All domain names for the current certificate as specified in domains.txt.
    #   Again, this doesn't need to match with the CSR, it's just there for convenience.

    # Simple example: Look for pre-generated CSRs
    # if [ -e "${CERTDIR}/pre-generated.csr" ]; then
    #   cat "${CERTDIR}/pre-generated.csr"
    # fi
}


startup_hook() {
    # This hook is called before the cron command to do some initial tasks
    # (e.g. starting a webserver).

    # Override the default log line/mail subject prefix.
    local LOG_PREFIX="Dehydrated startup"

    # Start thttpd if it's not running and its rc file is executable.
    [[ -x /etc/rc.d/rc.thttpd ]] && {
      if ! pgrep -c -F /run/thttpd.pid thttpd >/dev/null 2>&1; then
        /etc/rc.d/rc.thttpd start >/dev/null 2>&1
        sleep 1
        if pgrep -c -F /run/thttpd.pid thttpd >/dev/null 2>&1; then
          # Set a marker (used in the exit_hook function) to signal that thttpd should be stopped at the end of deployments.
          touch /run/dehydrated-thttpd-stop-marker || notify "warning" "Failed to create thttpd stop marker - thttpd will be left running."
          # Add firewall rules to allow http traffic.
          iptables -I INPUT 1 -p tcp --syn --dport 80 -m conntrack --ctstate NEW -j ACCEPT && \
              ip6tables -I INPUT 1 -p tcp --syn --dport 80 -m conntrack --ctstate NEW -j ACCEPT || {
            notify "error" "Failed to insert firewall rules to allow validation."
            return 1
          }
        else
          notify "error" "Failure of 'rc.thttpd start'."
          return 1
        fi
      else
        notify "warning" "thttpd is already running - will not be shutdown at exit."
      fi
    }

    return 0
}


exit_hook() {
    local ERROR="${1}"

    # This hook is called at the end of the cron command and can be used to
    # do some final (cleanup or other) tasks.
    #
    # Parameters:
    # - ERROR
    #   Contains error message if dehydrated exits with error

    # Override the default log line/mail subject prefix.
    local LOG_PREFIX="Dehydrated shutdown"

    # Stop thttpd if it was started in startup_hook.
    [[ -e /run/dehydrated-thttpd-stop-marker ]] && {
      # Stop thttpd.
      if [[ -x /etc/rc.d/rc.thttpd ]]; then
        /etc/rc.d/rc.thttpd stop >/dev/null 2>&1
        sleep 1
        pgrep -c -F /run/thttpd.pid thttpd >/dev/null 2>&1 && notify "error" "Failed to stop thttpd."
      else
        notify "error" "'rc.thttpd' not executable, but thttp stop marker eexists - not stopped."
      fi

      # Delete firewall rules which allow http traffic.
      iptables -D INPUT -p tcp --syn --dport 80 -m conntrack --ctstate NEW -j ACCEPT && \
          ip6tables -D INPUT -p tcp --syn --dport 80 -m conntrack --ctstate NEW -j ACCEPT || \
          notify "warning" "Failed to delete firewall rules added for validation."

      # Remove the stop marker.
      rm -f /run/dehydrated-thttpd-stop-marker || notify "error" "Failed to remove thttpd stop marker."
    }

    # If the marker was set by deploy_cert(), restart services.
    [[ -e /run/dehydrated-reload-marker ]] && {
      # Restart Apache httpd if it's running and its rc file is executable.
      pgrep -c -F /run/httpd.pid httpd >/dev/null 2>&1 && {
        if [[ -x /etc/rc.d/rc.httpd ]]; then
          if [[ -x /usr/sbin/apachectl ]]; then
            if /usr/sbin/apachectl configtest >/dev/null 2>&1; then
              # A rc.httpd restart often craps out because Apache can take time to exit.  Do a stop/start instead.
              /etc/rc.d/rc.httpd stop >/dev/null 2>&1
              local TIMER=0 MAXTRIES=30
              while pgrep -c httpd >/dev/null 2>&1; do
                sleep 1
                (( TIMER == MAXTRIES )) && break
                (( TIMER++ ))
              done
              if (( TIMER != MAXTRIES )); then
                /etc/rc.d/rc.httpd start >/dev/null 2>&1 || notify "error" "'rc.httpd start' failed - httpd in uncertain state."
              else
                notify "error" "failed to stop httpd after ${MAXTRIES}s - httpd in uncertain state."
              fi
            else
              notify "warning" "'apachectl configtest' failed - not restarted."
            fi
          else
            notify "warning" "'apachectl' not executable, but httpd is running - not restarted."
          fi
        else
          notify "warning" "'rc.httpd' not executable, but httpd is running - not restarted."
        fi
      }

      # Restart proftpd if it's running and its rc file is executable.
      pgrep -c -F /run/proftpd.pid proftpd >/dev/null 2>&1 && {
        if [[ -x /etc/rc.d/rc.proftpd ]]; then
          if [[ -x /usr/sbin/proftpd ]]; then
            if /usr/sbin/proftpd -t >/dev/null 2>&1; then
              /etc/rc.d/rc.proftpd restart >/dev/null 2>&1 || notify "error" "'rc.proftpd restart' failed - proftpd in uncertain state."
            else
              notify "warning" "'proftpd -t' failed - not restarted."
            fi
          else
            notify "warning" "'proftpd' binary not executable, but ftpd is running - not restarted."
          fi
        else
          notify "warning" "'rc.proftpd' not executable, but ftpd is running - not restarted."
        fi
      }

     # Remove the restart marker.
      rm /run/dehydrated-reload-marker || notify "error" "Failed to remove reload marker."
    }

    return 0
}


HANDLER="$1"; shift
if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|sync_cert|deploy_cert|deploy_ocsp|unchanged_cert|invalid_challenge|request_failure|generate_csr|startup_hook|exit_hook)$ ]]; then
  "$HANDLER" "$@"
fi
