#!/bin/bash
#
# renew-letsencrypt-cert.sh
#
# Obtiene/renueva un certificado firmado por la CA de letsencrypt.org
#
# Copyright 2025 Felipe Sanchez
# Todos los derechos reservados
#




FECHA="20250718"
VERSION="1.3.0"

DEBUG=0
USA_MONDIR=1

######################################################
#                  Funciones utiles                  #
######################################################


MONDIR="/var/log/mon"
MONFILE=${MONDIR}/`basename $0`

termina_error() {
   # Registramos el suceso en MONDIR
   if [ $USA_MONDIR -gt 0 ]; then
      [ -d "$MONDIR" ] || mkdir -p "$MONDIR"
      if [ $? -gt 0 ]; then
         echo "*** Error al crear $MONDIR" >&2
      else
         echo ----- `date` ----- >> $MONFILE
         echo "*** error: $1" >> "$MONFILE"
         if [ -r $ERRFILE ]; then
            cat $ERRFILE >> "$MONFILE"
         fi
      fi
   fi

   # Informamos via stderr
   echo "*** error: $1" >&2
   if [ -r $ERRFILE ]; then
      cat $ERRFILE >&2
   fi

   rm -f $ERRFILE
   exit 1
}


despliega_uso() {
cat << EOF
Uso: $(basename $0) [opciones] <nombre de dominio> <usuario>

Obtiene/renueva un certificado firmado por la CA de letsencrypt.org

Opciones:

   -V                Muestra el numero de version
   -c <config>       Ruta al archivo de configuracion
   -a <account key>  Ruta a la llave de cuenta
   -d <ruta>         Directorio de datos de letsencrypt
   -D                Usar reto ACME DNS-01
   -i <ruta>         Archivo de configuracion de acme_dns_tiny
   -l <URL>          URL del servicio ACME
   -s <servicio1 servicio2 ...> Lista de servicios a reiniciar
   -p                Integrar certificado firmado y llave en este archivo .pem
   -P <prop:grupo>   propietario:grupo para el archivo PEM (Implica -p)
   -r <1|0>          Reiniciar servicios
   -R <rc|rcserv>    Estilo de control de servicios
   -x <ruta>         Ejecutar este script despues de firmar el certificado
   -t <ruta>         Ruta al ejecutable del cliente acme_tiny
   -T <ruta>         Ruta al ejecutable del cliente acme_dns_tiny
   -m <dias>         Edad minima de un certificado antes de ser firmado de nuevo
   -F                Forzar la renovacion del certificado
   -X <numero>       Cantidad maxima de fallas antes de que nos neguemos a renovar
   -v                Modo verboso


EOF
}


crea_temp() {
   # Archivo temporal
   local retfile=`mktemp ${TMPBASE}/$(basename $0).XXXXXX`
   if [ $? -gt 0 ]; then
      termina_error "Error al crear archivo temporal en $TMPBASE"
      exit 1
   fi

   echo $retfile
}

crea_temp_dir() {
   # Directorio temporal
   local retdir=`mktemp -d ${TMPBASE}/$(basename $0).XXXXXX`
   if [ $? -gt 0 ]; then
      termina_error "Error al crear directorio temporal en $TMPBASE"
      exit 1
   fi

   echo $retdir
}

#######################################################
#                      Defaults                       #
#######################################################

# Direccion del administrador del sistema
[ "$ADMIN" ] || ADMIN="monitor@asic-linux.com.mx"

# Archivo de configuracion
[ "$CONFIGFILE" ] || CONFIGFILE="/etc/$(basename $0).conf"

# Ruta para archivos temporales
[ "$TMPBASE" ] || TMPBASE="/tmp"

# El directorio base de letsencrypt
[ "$LEHOME" ]   || LEHOME="/etc/letsencrypt"

# Usar ACME-DNS
[[ "$ACMEDNS" ]] || ACMEDNS=0

# Ruta al archivo de configuracion de acme_dns_tiny.py
[[ "$ACMEDNS_INI" ]] || ACMEDNS_INI="${LEHOME}/acmedns.ini"

# Ruta al ejecutable de acme_tiny
[ "$ACMETINY" ] || ACMETINY=/usr/bin/acme_tiny.py

# Ruta al ejecutable de acme_dns_tiny
[[ "$ACMEDNSTINY" ]] || ACMEDNSTINY=/usr/bin/acme_dns_tiny.py

# Lista de servicios que deseamos notificar cuando haya
# un nuevo certificado disponible.
# Son nombres de archivos rc, sin el rc.
# Ej. "httpd dovecot qmail"
[ "$RESTART_LIST" ] || RESTART_LIST="httpd"

# Estilo de sistema de reiniciado de servicios
# rc:     Estilo Iztaci 2+ (rc restart servicio)
# rcserv: Estilo Iztaci 1 (rc.servicio restart)
[ "$RESTART_STYLE" ] || RESTART_STYLE="rc"

# Deseamos reiniciar los servicios de RESTART_LIST?
[ "$RESTART_SERVICES" ] || RESTART_SERVICES=1

# Ruta a la llave de cuenta
[ "$ACCOUNT_KEY" ] || ACCOUNT_KEY=${LEHOME}/account-keys/${NAME}-account.key

# Propietario y grupo del archivo PEM
[ "$PEM_OWNER_GROUP" ] || PEM_OWNER_GROUP="root:root"

# Edad minima (en dias) que debe tener un certificado
# antes de ser renovado.
[ "$MINDAYS" ] || MINDAYS=60

# Forzar la renovacion del certificado
# aunque este no tenga aun la edad minima
[ "$FORCE_RENEW" ] || FORCE_RENEW=0

# Cantidad maxima de fallas en la renovacion de un certificado.
# Al superar ese numero de fallas nos negaremos a intentar la
# renovacion a menos que seamos forzados con -F o si se elimina
# el registro de fallas en $VARLIB/failures
[ "$MAX_FAILURES" ] || MAX_FAILURES=5

# Activar modo verboso
[ "$VERBOSE" ] || VERBOSE=0

# Directorio de registro
[ "$VARLIB" ] || VARLIB=/var/lib/letsencrypt

#######################################################
#              Procesamos las opciones                #
#######################################################


while getopts ":Vc:d:l:s:r:t:a:hpP:R:m:FX:vDi:x:T:" opt; do
  case $opt in
    V)
      echo "$(basename $0) $VERSION, $FECHA"
      exit 0
      ;;
    c)
      CONFIGFILE="$OPTARG"
      ;;
    d)
      LEHOME="$OPTARG"
      ;;
    D)
      ACMEDNS=1
      ;;
    i)
      ACMEDNS_INI="$OPTARG"
      ;;
    l)
      ACMEURL="$OPTARG"
      ATINY_CA_FLAG="--ca $ACMEURL"
      ;;
    s)
      RESTART_LIST="$OPTARG"
      ;;
    r)
      RESTART_SERVICES="$OPTARG"
      ;;
    x)
      POSTRUN="$OPTARG"
      ;;
    R)
      RESTART_STYLE="$OPTARG"
      ;;
    t)
      ACMETINY="$OPTARG"
      ;;
    T)
      ACMEDNSTINY="$OPTARG"
      ;;
    a)
      ACCOUNT_KEY="$OPTARG"
      ;;
    p)
      CREATE_PEM=1
      ;;
    P)
      PEM_OWNER_GROUP="$OPTARG"
      CREATE_PEM=1
      ;;
    m)
      MINDAYS="$OPTARG"
      ;;
    F)
      FORCE_RENEW=1
      ;;
    X)
      MAX_FAILURES="$OPTARG"
      ;;
    v)
      VERBOSE=1
      ;;
    h)
      despliega_uso
      exit 1
      ;;
    \?)
      echo "$(basename $0): Opcion invalida: -$OPTARG" >&2
      despliega_uso
      exit 1
      ;;
    :)
      echo "$(basename $0): Opcion -$OPTARG requiere un argumento" >&2
      exit 1
      ;;
  esac
done

shift $(($OPTIND-1))

# Log de errores
ERRFILE=$( crea_temp ) || termina_error "Error al crear el archivo ERRFILE"

# Leemos el archivo de configuracion
if [ -f "$CONFIGFILE" ]; then
   source "$CONFIGFILE" 2>>$ERRFILE || termina_error "Leyendo el archivo de configuracion $CONFIGFILE"
fi

#####################################################
#            Verificaciones de sanidad              #
#####################################################

[ -d "$LEHOME" ]         || termina_error "El directorio de datos de letsencrypt $LEHOME no existe"
[ -d "$LEHOME/certs" ]   || termina_error "El directorio de certificados $LEHOME/certs no existe"
[ -d "$LEHOME/keys" ]    || termina_error "El directorio de llaves $LEHOME/keys no existe"
[ -d "$LEHOME/account-keys" ]  || termina_error "El directorio de llaves de cuenta $LEHOME/account-keys no existe"
[ -d "$LEHOME/challenges" ]    || termina_error "El directorio de retos ACME $LEHOME/challenges no existe"
[ -d "$LEHOME/csr" ]     || termina_error "El directorio de solicitudes de firmado $LEHOME/csr no existe"
[ -x "$ACMETINY" ]       || termina_error "acme_tiny ($ACMETINY) no es ejecutable"

if [[ "$ACMEDNS" -eq 1 ]]; then
  [[ -x "$ACMEDNSTINY" ]] ||  termina_error "acme_dns_tiny ($ACMEDNSTINY) no es ejecutable"
  [[ -r "$ACMEDNS_INI" ]] ||  termina_error "El archivo de configuracion para acme_dns_tiny ($ACMEDNS_INI) no es legible"
fi

if [[ -n "$POSTRUN" ]]; then
  [[ -x "$POSTRUN" ]] || termina_error "El script post-ejecucion ($POSTRUN) no es ejecutable"
fi







#####################################################
#                 Bucle principal                   #
#####################################################

main () {

NAME=$1
USER=$2


if [ -z "$2" ]; then
    despliega_uso
    exit 1
fi

KEYDIR=${LEHOME}/keys
CERTDIR=${LEHOME}/certs
CSR=${LEHOME}/csr/${NAME}.csr
CHALL_DIR=${LEHOME}/challenges/${NAME}
SIGNED_CERT="${CERTDIR}/${NAME}.signed.cert"
FCOUNT_FILE="${VARLIB}/failures/${NAME}"

mkdir -p "${VARLIB}/failures/"

# Verificamos que el certificado ya tenga edad suficiente para ser renovado
if [[ $FORCE_RENEW -eq 0 ]]; then
   if [[ -f "$SIGNED_CERT" ]]; then
      sigmtime=$(stat -c%Y "$SIGNED_CERT")
      curtime=$(date +%s)
      if [[  $(( $curtime - $sigmtime )) -lt $(( $MINDAYS * 86400 )) ]]; then
         if [[ $VERBOSE -eq 1 ]]; then
            ( echo
              echo "El certificado '$SIGNED_CERT' parece haber sido renovado"
              echo "hace menos que $MINDAYS dias."
              echo
              echo "Usa la opcion -F para intentar renovarlo de cualquier manera."
              echo ) >&2
         else
            echo "'$SIGNED_CERT' aun es muy nuevo para renovarse ($(( ($curtime - $sigmtime) / 86400))/${MINDAYS} dias)" >&2
         fi
         exit 0
      fi
   fi
fi


# Tambien verificamos que no hayamos fallado demasiadas veces
# para no disparar los controles de recursos de LE

# Inicializamos la cuenta si el archivo de registro de fallas no existe
[[ -f "$FCOUNT_FILE" ]] || echo 0 > "$FCOUNT_FILE"

fcount=$(cat "$FCOUNT_FILE")

if [[ $FORCE_RENEW -eq 0 ]]; then
   if [[ "$fcount" -ge $MAX_FAILURES ]]; then
      ( echo "La renovacion de '$SIGNED_CERT' ha fallado mas de $MAX_FAILURES veces seguidas."
        echo
        echo "Averigua cual es el problema y solucionalo. Despues elimina el archivo:"
        echo
        echo "$FCOUNT_FILE"
        echo
        echo "o usa la opcion -F para forzarme a intentar la renovacion." ) >&2
        exit 113
   fi
fi

# Solicitamos que firmen nuestro certificado.
# Si la cuenta no existe acme-tiny tratara de crearla automaticamente.

if [[ "$ACMEDNS" -eq 1 ]]; then
   sudo -u "${USER}" "$ACMEDNSTINY" --csr "$CSR" "$ACMEDNS_INI" > "${SIGNED_CERT}.tmp"
   retval=$?
else
   sudo -u "${USER}" "$ACMETINY" $ATINY_CA_FLAG \
                        --account-key "$ACCOUNT_KEY" \
                        --csr "$CSR" \
                        --acme-dir "$CHALL_DIR" > "${SIGNED_CERT}.tmp"

   retval=$?
fi

if [[ $retval -gt 0 ]]; then
   echo $((fcount + 1)) > "$FCOUNT_FILE"
   termina_error "Error al renovar certificado para $NAME"
else
   rm -f "$FCOUNT_FILE"
fi

# Creamos el certificado encadenado
cat "${SIGNED_CERT}.tmp" "${CERTDIR}/letsencrypt-intermediate.pem"  > "${CERTDIR}/${NAME}.chained.cert"

# Dejamos el nuevo certificado con su nombre definitivo
mv  "${SIGNED_CERT}.tmp" "$SIGNED_CERT"

# Integramos el certificado firmado y la llave en un .pem
if [[ "$CREATE_PEM" -eq 1 ]]; then
   PEMFILE=${CERTDIR}/${NAME}.pem
   echo
   echo "Creando archivo $PEMFILE"
   echo
   oldumask=$(umask)
   umask 0337
   rm -f "$PEMFILE"
   cat ${CERTDIR}/${NAME}.signed.cert ${KEYDIR}/${NAME}.key > "$PEMFILE"
   chown -v "$PEM_OWNER_GROUP" "$PEMFILE"
   umask $oldumask
fi

# Reiniciamos los servicios que sea necesario reiniciar
if [ "$RESTART_SERVICES" = 1 ]; then
   case "$RESTART_STYLE" in
   "rc")
      for servicio in $RESTART_LIST; do
         /sbin/rc restart "${servicio}"
      done
      ;;
   "rcserv")
      for servicio in $RESTART_LIST; do
         "/etc/rc.d/rc.${servicio}" restart
      done
      ;;
   "systemd")
      for servicio in $RESTART_LIST; do
         systemctl restart "$servicio"
      done
      ;;
   esac
fi

# Si tenemos un script post-ejecucion, lo ejecutamos

if [[ -n "$POSTRUN" ]]; then
   echo "Ejecutando postrun: $POSTRUN"
   "$POSTRUN"
   [[ $? -gt 0 ]] && termina_error "Error al ejecutar el script post-ejecucion ($POSTRUN)"
fi

}

main "$@"
exit $?
