#!/bin/bash

## avoid evil aliases by clearing all of them
## but call this shell builtin within quotes, in case the word "unalias" itself
## was aliased.
"unalias" -a

## set PATH to a known value if an attacker could only gain very restricted
## control over the user's account, and using some evil value for PATH would
## call unwanted executeables
export PATH="/usr/bin:/bin"

## make use of undefined variables a fatal error
set -u

## create files with restrictive umask by default, because
##   $ touch file
##   $ chmod og-rwx file
## would give the attacker an opportunity to open the file before access
## is restricted.
umask 077




###############################################################################
############################## TABLE OF CONTENTS ##############################
###############################################################################
##
## - exit code declarations
##    - bash codes
##    - sysexits.h codes
##    - additional exit codes
## - initial checks and global variables
##    - test for necessary executeables
##    - globals holding directory names/paths
##    - globals configurable by user
## - utility procedures
## - printhelp
## - command procedures
##    - parse_cmdline
##    - fsck_everything
##    - new_letsencrypt_account
##    - item_new_key
##    - item_new_csr
##    - item_submit_csr_to_letsencrypt
##    - item_chain_retrieve
##    - item_activate
##    - item_fail
##    - automatic_letsencrypt
##    - automatic_selfsign
## - main
##




###############################################################################
################################# EXIT CODES ##################################
###############################################################################

################################
#### codes reserved by bash ####
EX_OK=0
EX_GENERAL_ERROR=1
EX_MISUSE_OF_BUILTIN=2
# gap from 3 to 125
EX_COMMAND_CANNOT_EXEC=126
EX_COMMAND_NOT_FOUND=127
EX_INVALID=128
EX_SIGNAL_1=129
EX_SIGNAL_2=130
EX_SIGNAL_INT=130
EX_SIGNAL_9=137
EX_SIGNAL_KILL=137
EX_SIGNAL_15=143
EX_SIGNAL_TERM=143
# gap from 166 to 254
EX_OUT_OF_RANGE=255

###############################
#### codes from sysexits.h ####
EX_OK=0
# gap from 1 to 63
EX_BASE=64
EX_USAGE=64
EX_DATAERR=65
EX_NOINPUT=66
EX_NOUSER=67
EX_NOHOST=68
EX_UNAVAILABLE=69
EX_SOFTWARE=70
EX_OSERR=71
EX_OSFILE=72
EX_CANTCREAT=73
EX_IOERR=74
EX_TEMPFAIL=75
EX_PROTOCOL=76
EX_NOPERM=77
EX_CONFIG=78
# gap from 78 to 255

##############################
#### my custom exit codes ####
EX_OK=0
EX_FAIL=1
# use range 16 to 32 for custom errors:
EX_NO=16




###############################################################################
##################### INITIAL CHECKS AND GLOBAL VARIABLES #####################
###############################################################################


###############################################
#### test if neccessary executeables exist ####

function checkfilex() {
    [[ -e "$1" ]] || {
        echo "ERROR: file not found: $1" >&2
        exit "$EX_OSFILE"
    }
    [[ -x "$1" ]] || {
        echo "ERROR: file not executable: $1" >&2
        exit "$EX_OSFILE"
    }
    [[ -d "$1" ]] && {
        echo "ERROR: file is a directory: $1" >&2
        exit "$EX_OSFILE"
    }
    return "$EX_OK"
}
checkfilex /bin/cat
checkfilex /bin/chmod
checkfilex /bin/cp
checkfilex /bin/date
checkfilex /bin/mktemp
checkfilex /bin/readlink
checkfilex /bin/rm
checkfilex /bin/rmdir
checkfilex /bin/sed
checkfilex /usr/bin/basename
checkfilex /usr/bin/curl
checkfilex /usr/bin/curl
checkfilex /usr/bin/cut
checkfilex /usr/bin/dirname
checkfilex /usr/bin/find
checkfilex /usr/bin/openssl
checkfilex /usr/bin/sha1sum
checkfilex /usr/bin/uniq
checkfilex /usr/bin/wc
unset -f checkfilex


###################################
#### directory names and paths ####

MYNAME="$0"
PACKAGE="acme-wrapper"
mydir="$(dirname "$0")"
etcdir="/etc/${PACKAGE}"
acme_tiny="/usr/bin/acme-tiny"
varlibdir="/var/lib/${PACKAGE}"
usrlibdir="/usr/lib/${PACKAGE}"
spooldir="/var/spool/${PACKAGE}"
databasedir="${varlibdir}/database"
letsencryptaccountdir="${varlibdir}/letsencrypt-account"
opensslconfig="${usrlibdir}/openssl.cnf"


##############################################
#### test if neccessary directories exist ####

function checkdir() {
    [[ -d "$1" ]] || {
        echo "ERROR: directory not found: $1" >&2
        exit "$EX_OSFILE"
    }
    return "$EX_OK"
}
function checkfile() {
    [[ -f "$1" ]] || {
        echo "ERROR: file not found: $1" >&2
        exit "$EX_OSFILE"
    }
    return "$EX_OK"
}
checkfile "${acme_tiny}"
unset -f checkfile
unset -f checkdir


###############################################
## general purpose temporary file on disk
## automatically freed by OS upon exit
TEMPFILEPATH="$(mktemp)"
exec 3<>"${TEMPFILEPATH}"
rm -f "${TEMPFILEPATH}"
TEMPFILEPATH="/proc/self/fd/3"



##############################################
#### global variables with default values ####

# log level, defaults to WARN
# but can be changed by cmd-line options
# MUST be one of: debug, info, warn, error
LOG_LEVEL="warn"
LOG_LEVEL_ENUM_ALL=("debug" "info" "warn" "error")
LOG_LEVEL_ENUM_DEBUG=("debug")
LOG_LEVEL_ENUM_INFO=("debug" "info")
LOG_LEVEL_ENUM_WARN=("debug" "info" "warn")
LOG_LEVEL_ENUM_ERROR=("debug" "info" "warn" "error")

# the configuration file used
CONFIG_FILEPATH="${etcdir}/acme-wrapper.conf"

# the domains.list file used
DOMAINSLIST_FILEPATH="${etcdir}/domains.list"

# whether the command line specified --all-domains (i.e. check/process all
# requested certificates from etc/domains.list)
OPT_ALL_DOMAINS=0

# all COMMAND options specified
declare -a CMDS
CMDS=()

# the active configuration line: a space-separated list of fully qualified
# domain names, here stored in form of a bash array
declare -a FQDNS
FQDNS=()



###############################################################################
############################# UTILITY PROCEDURES ##############################
###############################################################################

##
## just echo to stderr
##
## parameters: any
## stdout: no
## stderr: the parameters
##
function echoerr() {
    echo "$@" >&2
    return $EX_OK
}

##
## test if the first argument is equal to any of the other arguments
## using with an array like this:
##     StringEqualToAnyOf "$needle" "${haystack[@]}"
## the return value is $EX_OK if $needle is equal to any of the other strings,
## or $EX_NO otherwise.
##
## if there are zero haystack-strings (the empty set), $EX_NO is returned.
## if also needle is missing, the result is undefined.
##
## Synopsis:
##     StringEqualToAnyOf NEEDLE [HAYSTACKELEMENT1] [HAYSTACKELEMENT2] ...
##
function StringEqualToAnyOf() {
    [[ "$#" -eq 0 ]] && {
        # no needle given
        logerror "misuse of ${FUNCNAME[0]}: NEEDLE mssing."
        return $EX_MISUSE_OF_BUILTIN
    }
    [[ "$#" -eq 1 ]] && {
        # needle is never part of the empty set
        return $EX_NO
    }
    local e
    for e in "${@:2}"; do
        [[ "$e" == "$1" ]] && {
            return $EX_OK;
        }
    done
    return $EX_NO
}

##
## log messages, if appropriate log level
## currently the log message is only logged to stderr, but an improvement
## would be support for syslog or similar
##
## parameters: the message text
## stderr: the log message
##
function logerror() {
    StringEqualToAnyOf "${LOG_LEVEL}" "${LOG_LEVEL_ENUM_ERROR[@]}" && {
        echo "[$$] ERROR: ${FUNCNAME[1]}: $@" >&2
    }
    return $EX_OK
}

## see logerror
function logwarn() {
    StringEqualToAnyOf "${LOG_LEVEL}" "${LOG_LEVEL_ENUM_WARN[@]}" && {
        echo "[$$] WARN: ${FUNCNAME[1]}: $@" >&2
    }
    return $EX_OK
}

## see logerror
function loginfo() {
    StringEqualToAnyOf "${LOG_LEVEL}" "${LOG_LEVEL_ENUM_INFO[@]}" && {
        echo "[$$] INFO: ${FUNCNAME[1]}: $@" >&2
    }
    return $EX_OK
}

## see logerror
function logdebug() {
    StringEqualToAnyOf "${LOG_LEVEL}" "${LOG_LEVEL_ENUM_DEBUG[@]}" && {
        echo "[$$] DEBUG: ${FUNCNAME[1]}: $@" >&2
    }
    return $EX_OK
}


##
## return a new date_identifier
##
## parameters: FQDN [SANs]
## stdout: date_identifier
##
DateIdentifier_counter="${DateIdentifier_counter:-0}"
function DateIdentifier()
{
    [[ "$#" -gt 0 ]] || {
        logerror "need FQDNS as parameters"
        return $EX_MISUSE_OF_BUILTIN
    }
    local identifier="$(SpoolIdentifier "$@")"
    local datestr
    datestr="$(date +%Y-%m-%d-%H-%M-%S)-${UniqueIdentifier_counter}-$$"
    [[ "$?" -ne "${EX_OK}" ]] && {
        logerror "could not call 'date'"
        return $EX_OSFILE
    }
    local dateidentifier="${datestr}-${identifier}"
    ((UniqueIdentifier_counter++))
    echo -n "${dateidentifier}"
    return $EX_OK
}


##
## prints to stdout a random identifier usable for directory or file names.
##
## tries to return unique names, even if
##   - called in rapid succession
##   - simultaniously called by multiple processes
## tries to make the directories date-sortable by normal human interfaces
##
## parameters: none
## stdout: a non-empty string restricted to characters [a-zA-Z0-9-]
##
UniqueIdentifier_counter="${UniqueIdentifier_counter:-0}"
function UniqueIdentifier() {
    local str
    str="$(date +%Y-%m-%d-%H-%M-%S)-${UniqueIdentifier_counter}-$$"
    [[ "$?" -ne "${EX_OK}" ]] && {
        logerror "could not call 'date'"
        return $EX_OSFILE
    }
    ((UniqueIdentifier_counter++))
    echo -n "${str}"
    return $EX_OK
}


##
## prints to stdout a determenistic identifier usable for directory or file
## names.
## equal parameters result in equal identifiers.
## unequal parameters most probably result in unequal identfiers.
##
## implementation detail: currently a UUID Version 5 with unspecified namespace
## is created.
##
## parameters: FQDN [SANs]
## stdout: a non-empty string restricted to characters [a-zA-Z0-9-]
##
function SpoolIdentifier() {
    local name="$(echo "DNS:${@}" | sed -re 's/ /,DNS:/g')"
    local sha1
    sha1="$(sha1sum <<<"$name" | cut -d " " -f1)"
    [[ "$?" -ne "${EX_OK}" ]] && {
        logerror "could not call 'sha1sum'"
        return $EX_OSFILE
    }
    local version="5"
    local bits="${sha1:12:1}"
    case "${bits}" in
        0)    bits="8" ;;
        1)    bits="9" ;;
        2)    bits="a" ;;
        3)    bits="b" ;;
        4)    bits="8" ;;
        5)    bits="9" ;;
        6)    bits="a" ;;
        7)    bits="b" ;;
        8)    bits="8" ;;
        9)    bits="9" ;;
        a|A)  bits="a" ;;
        b|B)  bits="b" ;;
        c|C)  bits="8" ;;
        d|D)  bits="9" ;;
        e|E)  bits="a" ;;
        f|F)  bits="b" ;;
        *)
            logerror "Programming error"
            exit $EX_SOFTWARE
    esac
    local uuid="${sha1:0:8}-${sha1:8:4}-${version}${sha1:13:3}-${bits}${sha1:17:3}-${sha1:20:12}"
    echo -n "${uuid}"
    return $EX_OK
}


##
## check the given database item for errors
##
## parameters: date_identifier of the database item
## return: EX_OK if everything seems in order, EX_NO otherwise
##
function FsckDabsItem() {
    [[ "$#" -eq 1 ]] || {
        logerror "need one argument"
        return $EX_MISUSE_OF_BUILTIN
    }

    local dateidentifier="$1"
    local dabsitemdirpath="${databasedir}/items/${dateidentifier}"

    logdebug "FsckDabsItem: ${dabsitemdirpath}"

    local dabskeyfilepath="${dabsitemdirpath}/key.pem"
    local dabscrtfilepath="${dabsitemdirpath}/crt.pem"
    local dabschainfilepath="${dabsitemdirpath}/chain.pem"
    local dabscrtchainfilepath="${dabsitemdirpath}/crt+chain.pem"
    local dabscrtchainkeyfilepath="${dabsitemdirpath}/crt+chain+key.pem"
    local dabscafilepath="${dabsitemdirpath}/ca.pem"
    local dabsisfailed="${dabsitemdirpath}/isfailed.bool"
    local dabsisselfsign="${dabsitemdirpath}/isselfsign.bool"

    ## if item is marked as failed, it sticks
    [[ -e "${dabsisfailed}" && "$(<"${dabsisfailed}")" -ne 0 ]] && {
        logdebug "marked as failed: ${dabsisfailed}"
        return $EX_NO
    }

    ## key.pem must exist and be readable
    [[ -e "${dabskeyfilepath}" && -r "${dabskeyfilepath}" ]] || {
        logdebug "not found or not readable: ${dabskeyfilepath}"
        return $EX_NO
    }
    ## crt.pem must exist
    [[ -e "${dabscrtfilepath}" && -r "${dabscrtfilepath}" ]] || {
        logdebug "not found or not readable: ${dabscrtfilepath}"
        return $EX_NO
    }
    ## chain.pem must exist
    [[ -e "${dabschainfilepath}" && -r "${dabschainfilepath}" ]] || {
        logdebug "not found or not readable: ${dabschainfilepath}"
        return $EX_NO
    }
    ## crt+chain.pem must exist
    [[ -e "${dabscrtchainfilepath}" && -r "${dabscrtchainfilepath}" ]] || {
        logdebug "not found or not readable: ${dabscrtchainfilepath}"
        return $EX_NO
    }
    ## crt+chain+key.pem must exist
    [[ -e "${dabscrtchainkeyfilepath}" && -r "${dabscrtchainkeyfilepath}" ]] || {
        logdebug "not found or not readable: ${dabscrtchainkeyfilepath}"
        return $EX_NO
    }
    ## ca.pem must exist
    [[ -e "${dabscafilepath}" && -r "${dabscafilepath}" ]] || {
        logdebug "not found or not readable: ${dabscafilepath}"
        return $EX_NO
    }

    ## verify that certificate matches private key:
    ## if command produces more than 1 line, fingerprints mismatch and thus
    ## public key parts are different
    local keydigest
    keydigest="$(set -o pipefail;
        openssl rsa  -noout -modulus -in "${dabskeyfilepath}" \
        | openssl sha256)"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call 'openssl'"
        return $EX_OSERR
    }
    local crtdigest
    crtdigest="$(set -o pipefail;
        openssl x509 -noout -modulus -in "${dabscrtfilepath}" \
        | openssl sha256)"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call 'openssl'"
        return $EX_OSERR
    }

    ## Note: can only do this, because the sha256 digest never contains '\'
    local cnt
    cnt="$(set -o pipefail; \
        echo -e "${keydigest}\n${crtdigest}" \
        | uniq \
        | wc -l)"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call '| uniq | wc -l'"
        return $EX_OSERR
    }
    [[ "${cnt}" -eq 1 ]] || {
        logdebug "key and crt fingerprints mismatch: ${dabsitemdirpath}"
        return $EX_NO
    }


    ## verify that the certificate chain validates
    ## note: this also checks the expiry date of the certificate
    ## against the current system time
    local chainopts=("-untrusted" "${dabschainfilepath}")
    [[ -s "${dabschainfilepath}" ]] || {
        logdebug "skipping chain.pem file parameter of 'openssl verify'"
        chainopts=( )
    }
    logdebug "calling 'openssl verify'"
    openssl verify -verbose \
        -CAfile "${dabscafilepath}" \
        "${chainopts[@]}" \
        "${dabscrtfilepath}" \
        >/dev/null
    [[ "$?" -eq 0 ]] || {
        logdebug "does not verify against ${dabscafilepath} +" \
            "${dabschainfilepath}: ${dabscrtfilepath}"
        return $EX_NO
    }


    return $EX_OK
}



##
## check if the given database item should be renewed
## parameters: date_identifier of database item
## return: EX_OK if everything seems in order, EX_NO if the DabsItem should be
##   renewed
##
function ShouldRenewDabsItem() {

    local dateidentifier="$1"
    local dabsitemdirpath="${databasedir}/items/${dateidentifier}"

    FsckDabsItem "${dateidentifier}"
    case "$?" in
        $EX_OK) ;; # ok, go on
        $EX_NO)
            loginfo "FSCK-FAIL for ${dateidentifier}"
            return "$EX_NO"
        ;;
        *)
            logerror "could not call procedure 'FsckDabsItem' on database"\
                "item: ${dateidentifier}"
            return $EX_SOFTWARE
        ;;
    esac

    # check if the, otherwise good, database item will soon expiry
    local dabscrtfilepath="${dabsitemdirpath}/crt.pem"
    openssl x509 -checkend "${RESUBMIT_BEFORE}" \
        -in "${dabscrtfilepath}" \
        >/dev/null 2>/dev/null
    [[ "$?" -eq "$EX_OK" ]] || {
        loginfo "EXPIRED ${dateidentifier}"
        return "$EX_NO"
    }

    logdebug "needs no renew: ${dateidentifier}"
    return "$EX_OK"
}


###############################################################################
################################## PRINTHELP ##################################
###############################################################################

##
## help output
##
## stderr: the help
##
function printhelp() {
    echo "NAME"
    echo "    acme-wrapper -- automatic ssl-certificate retriever and database"
    echo ""
    echo "SYNOPSIS"
    echo "    ${MYNAME} [OPTIONS] [COMMAND] [--all-XXXX | FQDN [SANs]]"
    echo ""
    echo "SUMMARY"
    echo ""
    echo "  Tries to have rsa keys and x509 certificates available for the"
    echo "configured domain names. This script generates rsa keypairs,"
    echo "certificate signing requests, self-signed certificates and optionally"
    echo "submits the csrs to letsencrypt.org in order to retrieve issued"
    echo "certificates. Finally the resulting key and crt are stored with"
    echo "several flavors so that different programs all find their required"
    echo "format provided."
    echo ""
    echo "  This script wraps 'openssl' and 'acme-tiny.py' which do the"
    echo "actual work."
    echo ""
    echo "  Updates in the database directory are made atomatically by first"
    echo "preparing the new directory and only switching the symlink when"
    echo "all checks pass."
    echo ""
    echo ""
    echo "DEPENDENCIES:"
    echo ""
    echo "  Generation of rsa keys, csr, validation etc are"
    echo "done using the program 'openssl'."
    echo ""
    echo "  Communication with letsencrypt's acme server is done using"
    echo "Daniel Roesler's acme-tiny <https://github.com/diafygi/acme-tiny>"
    echo "script."
    echo ""
    echo ""
    echo "OPTIONS:"
    echo ""
    echo "  -q   synonym for --loglevel error"
    echo ""
    echo "  -v --verbose  synonym for --loglevel info"
    echo ""
    echo "  --loglevel [LEVEL]  changes the logging level printed to stderr"
    echo "      possible levels: debug, info, warn, error."
    echo "      Default: ${LOG_LEVEL}"
    echo ""
    echo "  --config  specify an alternative configuration file."
    echo "      Default is '${CONFIG_FILEPATH}'."
    echo "      Caveat: if you just specify a filename, it will be resolved"
    echo "      relative to the working directory (like any relative path)"
    echo "      and additionally searched for in PATH. This behaviour should"
    echo "      be fixed in a future version."
    echo ""
    echo ""
    echo "COMMAND:"
    echo ""
    echo "  --help  display this help and exit"
    echo ""
    echo "  --fsck_everything  perform selfcheck on spool and database"
    echo ""
    echo "  --new-letsencrypt-account  generate a new rsa key-pair used as"
    echo "      letsencrypt account"
    echo ""
    echo "  --new-item  creates a new spool item for the given FQDN+SANS."
    echo "      Each FQDNS+SANs may at most have one spool item at a given time."
    echo ""
    echo "  --item-new-key  create a new rsa keypair in the spool item"
    echo "      for the FQDN+SANs combination."
    echo "      Key generation is done with the program 'openssl' found via"
    echo "      the PATH environment variable."
    echo ""
    echo "  --item-new-csr  create a new certificate signing request in the"
    echo "      spool item of the FQDN+SANs combination. if SANs is"
    echo "      non-empty, the x509 extension 'Subject Alternative Name'"
    echo "      will be used."
    echo ""
    echo "  --item-self-sign  self-signs the spool item's csr with the own"
    echo "      rsa key."
    echo ""
    echo "  --item-submit-csr-to-letsencrypt  submit the csr from the spool"
    echo "      item to letsencrypt in order to receive a (new) certificate"
    echo "      with CN set to the first fqdn and all fqdns set as SAN (if"
    echo "      there are more than one fqdn)."
    echo ""
    echo "      Challenges are placed into the ACME_CHALLENGE_DIR directory"
    echo "      which must be available via http(s) to the letsencrypt"
    echo "      server at the appropriate URLs."
    echo ""
    echo "  --item-chain-retrieve  inspect the certificate in the spool item"
    echo "      for and retrieves the certificates up the chain, as"
    echo "      referenced inside the certificate(s)."
    echo "      will create a 'crt+chain' file with certificate and chain,"
    echo "      and a 'crt+chain+key' file with additionally the key."
    echo ""
    echo "  --item-activate  the spool item is checked for errors, and if"
    echo "      everything seems in order, is committed to the database."
    echo "      By committing, the content of an item will not be changed"
    echo "      or amended. After committing, the appropriate symlinks will"
    echo "      be switched, thus making available the new certificate to"
    echo "      applications."
    echo ""
    echo "  --item-fail  without further checking, the spool item is"
    echo "      permanently marked as 'failed' and moved commited to the"
    echo "      database. By committing, the content of an item will not be"
    echo "      changed or amended."
    echo "      No symlinks will be changed, thus the comitted item will not"
    echo "      be visible unless by explicit item-id."
    echo ""
    echo "  --automatic-letsencrypt  does sensible things to keep a"
    echo "      non-expired let's encrypt certificate ready in the database."
    echo "      Usually called from cron in combination with the"
    echo "      --all-domains option."
    echo "      Uses Letsencrypt Certification Authority to issue"
    echo "      certificates."
    echo ""
    echo "      In summary, what the command does is:"
    echo "      - check for all specified FQDN+SANs:"
    echo "        - whether the letsencrypt-item currently active for this"
    echo "          FQDN/SAN has no apparent errors and will not expire for"
    echo "          some time (configuration 'RESUBMIT_BEFORE')."
    echo "      - if all FQDNs/SANs are ok end processing successfully,"
    echo "        else continue."
    echo "      - create account key if not yet created"
    echo "      - if no spool item exists for this FQDN+SANs create a new spool"
    echo "        item."
    echo "      - inspect the spool item and execute the missing commands in"
    echo "        order:"
    echo "        - item-new-key"
    echo "        - item-new-csr"
    echo "        - item-submit-csr-to-letsencrypt"
    echo "        - item-chain-retrieve"
    echo "      - if everything went successful, execute 'item-activate'"
    echo "      - else if an unrecoverable error occured (i.e. the item is"
    echo "        believed to be damaged or is incompatible with letsencrypt),"
    echo "        execute 'item-fail'."
    echo ""
    echo "  --automatic-selfsign  does sensible things to keep a"
    echo "      non-expired self-signed certificate ready in the database."
    echo "      Usually called from cron in combination with the"
    echo "      --all-domains option."
    echo ""
    echo "      In summary, what the command does is:"
    echo "      - check for all specified FQDN+SANs (in case of '--all', for"
    echo "        each line in etc/domains.list):"
    echo "        - if the selfsigned-item currently active for this FQDN/SAN"
    echo "          has no apparent errors and will not expire for some time"
    echo "          (configuration RESUBMIT_BEFORE)."
    echo "      - if all FQDNs/SANs are ok end processing successfully,"
    echo "        else continue."
    echo "      - if no spool item exists for this FQDN+SANs create a new spool"
    echo "        item."
    echo "      - inspect the spool item and execute the missing commands"
    echo "        in order:"
    echo "        - item-new-key"
    echo "        - item-new-csr"
    echo "        - item-self-sign"
    echo "        - item-chain-retrieve"
    echo "      - if everything went successful, execute 'item-activate'"
    echo "      - else if an unrecoverable error occured (i.e. the item is"
    echo "        believed to be damaged or is incompatible with selfsign),"
    echo "        execute 'item-fail'."
    echo ""
    echo ""
    echo "FQDN and SANs:"
    echo ""
    echo "  if no --all-XXXX option is specified, the first non-option"
    echo "parameter is the fully qualified domain name of the certificate to be"
    echo "processed by the command. all other non-option parameters are"
    echo "additional fqdns for the same certificate, used as subject"
    echo "alternative name(s)."
    echo ""
    echo ""
    echo "  if --all-domains is specified, all lines from etc/domains.list"
    echo "are interpreted each as FQDNs with optional additional SANs. the"
    echo "specified command will be executed separately for each line."
    echo ""
    echo ""
    echo "SECURITY CONSIDERATIONS:"
    echo ""
    echo "  The installed \`acme-tiny\` is unconditionally and fully"
    echo "trusted."
    echo ""
    echo "  The system's \`openssl\` command is unconditionally and fully"
    echo "trusted."
    echo ""
    echo "  Until appropriate changes are made in this script, the config"
    echo "file is included via the \`source\` bash builtin command, and thus"
    echo "must also be subject to the same security measures as this"
    echo "script itself."
    echo ""
    return $EX_OK
}




###############################################################################
############################# COMMAND PROCEDURES ##############################
###############################################################################

##
## parse the given parameters as commandline
##
## parameters: the command line
## effects: sets the global user-configureable variables
##
function parse_cmdline() {
    [[ "$#" -eq 0 ]] && {
        # print "error message" if no parameters given
        printhelp
        return $EX_USAGE
    }

    while [[ "$#" -gt 0 ]]
    do
        local key="$1"

        case "$key" in
            # options
            -q)
                LOG_LEVEL="error"
                ;;
            -v|\
            --verbose)
                LOG_LEVEL="info"
                ;;
            --loglevel)
                arg="$2"
                shift
                StringEqualToAnyOf "$arg" "${LOG_LEVEL_ENUM_ALL[@]}"
                case "$?" in
                    $EX_OK)
                        LOG_LEVEL="$arg"
                        ;;
                    $EX_NO)
                        logerror "invalid loglevel"
                        return $EX_USAGE
                        ;;
                    *)
                        logerror "could not call procedure 'StringEqualToAnyOf'"
                        return $EX_UNAVAILABLE
                        ;;
                esac
                ;;
            --config)
                CONFIG_FILEPATH="$2"
                shift
                ;;

            ## commands ##
            --help)
                printhelp
                return $EX_USAGE
                ;;
            --fsck_everything)
                CMDS+=("fsck_everything")
                ;;
            --new-letsencrypt-account|\
            --new_letsencrypt_account)
                CMDS+=("new_letsencrypt_account")
                ;;
            --new-item|\
            --new_item)
                CMDS+=("new_item")
                ;;
            --item-new-key|\
            --item_new_key)
                CMDS+=("item_new_key")
                ;;
            --item-new-csr|\
            --item_new_csr)
                CMDS+=("item_new_csr")
                ;;
            --item-self-sign|\
            --item_self_sign)
                CMDS+=("item_self_sign")
                ;;
            --item-submit-csr-to-letsencrypt|\
            --item_submit_csr_to_letsencrypt)
                CMDS+=("item_submit_csr_to_letsencrypt")
                ;;
            --item-chain-retrieve|\
            --item_chain_retrieve)
                CMDS+=("item_chain_retrieve")
                ;;
            --item-activate|\
            --item_activate)
                CMDS+=("item_activate")
                ;;
            --item-fail|\
            --item_fail)
                CMDS+=("item_fail")
                ;;
            --automatic-letsencrypt|\
            --automatic_letsencrypt)
                CMDS+=("automatic_letsencrypt")
                ;;
            --automatic-selfsign|\
            --automatic_selfsign)
                CMDS+=("automatic_selfsign")
                ;;

            ## selection of FQDNS ##
            --all-domains)
                OPT_ALL_DOMAINS=1
                ;;

            ## unknown option ##
            --*)
                logerror "unknown option: $key"
                return $EX_USAGE
                ;;

            ## default for non-options is FQDN/SAN
            *)
                # warn if command is still empty
                [[ "${#CMDS[@]}" -eq 0 ]] && {
                    logwarn "no command-option yet specified," \
                        "but fqdn encountered: '$key'"
                }
                FQDNS+=("$key")
                ;;
        esac
        shift
    done

    # if no commands given, display help
    [[ "${#CMDS[@]}" -eq 0 ]] && {
        printhelp
        return $EX_USAGE
    }

    [[ "$OPT_ALL_DOMAINS" -eq 1 && "${#FQDNS[@]}" -gt 0 ]] && {
        logerror "option --all-domains and specifying FQDN"\
            "is mutually exclusive"
        return $EX_USAGE
    }

    return $EX_OK
}


##
## check the whole database and all spool items
##
function fsck_everything() {
    # FIXME: implement
    return $EX_OK
}


##
## always create a new account keypair
## FQDN and SANs are silently ignored
##
function new_letsencrypt_account() {
    # notify that account key is replaced
    [[ -e "${letsencryptaccountdir}/current" ]] && {
        local msgname
        msgname="${letsencryptaccountdir}/current"
        [[ -L "${msgname}" ]] && {
            msgname="$(readlink "$msgname")"
        }
        loginfo "replacing existing account keypair: ${msgname}"
    }

    local commit=1

    # make new directory in order to be able to roll back
    identifier="$(UniqueIdentifier)"
    [[ "$?" -eq "$EX_OK" && -n "${identifier}" ]] || {
        logerror "could not call procedure 'UniqueIdentifier'"
        return $EX_SOFTWARE
    }
    logdebug "CMMAND: ${identifier}"
    [[ ! -e "${letsencryptaccountdir}/${identifier}" ]] || {
        logerror "already exists: ${letsencryptaccountdir}/${identifier}"
        return $EX_SOFTWARE
    }
    mkdir "${letsencryptaccountdir}/${identifier}"
    [[ "$?" -ne "${EX_OK}" ]] && commit=0;

    chmod 0700 "${letsencryptaccountdir}/${identifier}"
    rm -f "${letsencryptaccountdir}/${identifier}/*"

    local keyfile="${letsencryptaccountdir}/${identifier}/account_key.pem"
    local pubfile="${letsencryptaccountdir}/${identifier}/account_pub.pem"

    local linktarget="${identifier}"
    local linkfile="${letsencryptaccountdir}/current"

    loginfo "Generating RSA keypair, ${RSA_KEY_BITS} bit long modulus"
    openssl genpkey -algorithm "RSA" \
        -pkeyopt "rsa_keygen_bits:${RSA_KEY_BITS}" \
        -out "${keyfile}" -outform "PEM" -text 2>/dev/null
    [[ "$?" -ne "${EX_OK}" ]] && commit=0;

    openssl rsa -in "${keyfile}" -check -noout
    [[ "$?" -ne "${EX_OK}" ]] && commit=0;

    openssl rsa -in "${keyfile}" -pubout -text > "${pubfile}"
    [[ "$?" -ne "${EX_OK}" ]] && commit=0;

    # commit transaction if no error
    if [[ "$commit" -eq 1 ]]; then
        logdebug "commit work"
        ln -snf "${linktarget}" "${linkfile}"
        loginfo "activated account keypair: ${identifier}"
    else
        logerror "not activating new account"
        return $EX_UNAVAILABLE
    fi
}


##
## create a new spool item
## error if it already exist
##
function new_item() {
    [[ "${#FQDNS[@]}" -ge 1 ]] || {
        logerror "no FQDN given: ${#FQDNS[@]}"
        return $EX_USAGE
    }

    local identifier
    identifier="$(SpoolIdentifier "${FQDNS[@]}")"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call procedure 'SpoolIdentifier'"
        return $EX_SOFTWARE
    }
    local spoolitemdirpath
    spoolitemdirpath="${spooldir}/${identifier}"
    [[ ! -e "${spoolitemdirpath}" ]] || {
        logerror "spool item already exists: ${spoolitemdirpath}"
        return $EX_USAGE
    }

    ## create the spool item directory
    mkdir "${spoolitemdirpath}"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call 'mkdir'"
        return $EX_OSERR
    }

    # store the FQDNS at the time of invoking
    echo "${FQDNS[@]}" > "${spoolitemdirpath}/domain.list"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not write file: ${spoolitemdirpath}/domain.list"
        return $EX_OSERR
    }

    return $EX_OK
}


##
## generate a new private key in spool item
## error if key already exists
##
function item_new_key() {
    [[ "${#FQDNS[@]}" -ge 1 ]] || {
        logerror "no FQDN given: ${#FQDNS[@]}"
        return $EX_USAGE
    }

    local identifier
    identifier="$(SpoolIdentifier "${FQDNS[@]}")"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call procedure 'SpoolIdentifier'"
        return $EX_SOFTWARE
    }
    local spoolitemdirpath="${spooldir}/${identifier}"
    local spoolkeyfilepath="${spoolitemdirpath}/key.pem"

    openssl genpkey -algorithm "RSA" \
        -pkeyopt "rsa_keygen_bits:${RSA_KEY_BITS}" \
        -out "${spoolkeyfilepath}" \
        -outform "PEM" -text 2>/dev/null
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call 'openssl'"
        return $EX_UNAVAILABLE
    }

    return $EX_OK
}


##
## generate a new certificate signing request in spool item
## error if csr already exists
## needs key already created inside spool item
##
function item_new_csr() {
    [[ "${#FQDNS[@]}" -ge 1 ]] || {
        logerror "no FQDN given: ${#FQDNS[@]}"
        return $EX_USAGE
    }

    local identifier
    identifier="$(SpoolIdentifier "${FQDNS[@]}")"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call procedure 'SpoolIdentifier'"
        return $EX_SOFTWARE
    }

    logdebug "COMMAND for ${FQDNS[@]} (${identifier})"

    local spoolitemdirpath="${spooldir}/${identifier}"
    local spoolkeyfilepath="${spoolitemdirpath}/key.pem"
    local spoolcsrfilepath="${spoolitemdirpath}/csr.pem"

    ## check if key already exists
    [[ -e "${spoolkeyfilepath}" ]] || {
        logerror "need private key to create csr: ${spoolkeyfilepath}"
        return $EX_USAGE
    }

    ## make csr without SAN
    if [[ "${#FQDNS[@]}" -eq 1 ]];
    then
        local csr_cn="${FQDNS[0]}"
        openssl req -text \
            -new -sha256 \
            -out "${spoolcsrfilepath}" \
            -key "${spoolkeyfilepath}" \
            -config <(cat "$opensslconfig" - <<EOF
[req_distinguished_name]
    CN = ${csr_cn}
EOF
            )
        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not call 'openssl'"
            return $EX_UNAVAILABLE
        }

        chmod ug+r "${spoolcsrfilepath}"

    ## make csr with SAN
    else
        local csr_cn="${FQDNS[0]}"
        local csr_san="$(echo "DNS:${FQDNS[@]}" | sed -re 's/ /,DNS:/g')"
        # set first domain as CN for compatibility
        # set all domains as SAN for compliance
        #    -addext "subjectAltName = ${csr_san}" \
        openssl req -text \
            -new -sha256 \
            -out "${spoolcsrfilepath}" \
            -key "${spoolkeyfilepath}" \
            -multivalue-rdn \
            -subj "/CN=${csr_cn}" \
            -reqexts SAN \
            -config <(cat "$opensslconfig" - <<EOF
[req_distinguished_name]
    CN = ${csr_cn}
[SAN]
    subjectAltName = ${csr_san}
EOF
            )

        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not call 'openssl'"
            return $EX_UNAVAILABLE
        }

        chmod ug+r "${spoolcsrfilepath}"
    fi

    return $EX_OK
}


##
## generate a self-signed certificate in spool-item
## error if a certificate already exists
## needs key already created inside spool item
## needs csr already created inside spool item
##
function item_self_sign() {
    [[ "${#FQDNS[@]}" -ge 1 ]] || {
        logerror "no FQDN given: ${#FQDNS[@]}"
        return $EX_USAGE
    }

    local identifier
    identifier="$(SpoolIdentifier "${FQDNS[@]}")"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call procedure 'SpoolIdentifier'"
        return $EX_SOFTWARE
    }

    logdebug "COMMAND for ${FQDNS[@]} (${identifier})"

    local spoolitemdirpath="${spooldir}/${identifier}"
    local spoolkeyfilepath="${spoolitemdirpath}/key.pem"
    local spoolcsrfilepath="${spoolitemdirpath}/csr.pem"
    local spoolcrtfilepath="${spoolitemdirpath}/crt.pem"

    ## check if key already exists
    [[ -e "${spoolkeyfilepath}" ]] || {
        logerror "need key: ${spoolkeyfilepath}"
        return $EX_USAGE
    }
    ## check if csr already exists
    [[ -e "${spoolcsrfilepath}" ]] || {
        logerror "need csr: ${spoolcsrfilepath}"
        return $EX_USAGE
    }

    ## check if certificate from other source already exists
    ## at this time only other source is letsencrypt
    local isletsencryptfilepath="${spoolitemdirpath}/isletsencrypt.bool"
    [[ -e "${isletsencryptfilepath}" &&
       "$(<"${isletsencryptfilepath}")" -ne 0
    ]] && {
        logerror "spool item is already signed by letsencrypt."\
            "make a new spool item."
        return $EX_USAGE
    }

    openssl x509 \
        -req -in "${spoolcsrfilepath}" \
        -signkey "${spoolkeyfilepath}" \
        -days 3650 \
        -text \
        > "${spoolcrtfilepath}" 2>&1

    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call 'openssl'"
        return $EX_UNAVAILABLE
    }

    echo 1 > "${spoolitemdirpath}/isselfsign.bool"

    chmod ug+r "${spoolcrtfilepath}"

    return $EX_OK
}


##
## use acme-tiny to retrieve the certificate from letsencrypt server
## error if a certificate already exists
##
function item_submit_csr_to_letsencrypt() {
    [[ "${#FQDNS[@]}" -ge 1 ]] || {
        logerror "no FQDN given: ${#FQDNS[@]}"
        return $EX_USAGE
    }

    local identifier
    identifier="$(SpoolIdentifier "${FQDNS[@]}")"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call procedure 'SpoolIdentifier'"
        return $EX_SOFTWARE
    }

    logdebug "COMMAND for ${FQDNS[@]} (${identifier})"

    local accountkeyfilepath="${letsencryptaccountdir}/current/account_key.pem"
    local spoolitemdirpath="${spooldir}/${identifier}"
    local spoolkeyfilepath="${spoolitemdirpath}/key.pem"
    local spoolcsrfilepath="${spoolitemdirpath}/csr.pem"
    local spoolcrtfilepath="${spoolitemdirpath}/crt.pem"

    ## check if account key already exists
    [[ -e "${accountkeyfilepath}" ]] || {
        logerror "need account key: ${accountkeyfilepath}"
        return $EX_USAGE
    }
    ## check if key already exists
    [[ -e "${spoolkeyfilepath}" ]] || {
        logerror "need key: ${spoolkeyfilepath}"
        return $EX_USAGE
    }
    ## check if csr already exists
    [[ -e "${spoolcsrfilepath}" ]] || {
        logerror "need csr: ${spoolcsrfilepath}"
        return $EX_USAGE
    }

    ## check if certificate from other source already exists
    ## at this time only other source is selfsign
    local isselfsignfilepath="${spoolitemdirpath}/isselfsign.bool"
    [[ -e "${isselfsignfilepath}" &&
       "$(<"${isselfsignfilepath}")" -ne 0
    ]] && {
        logerror "spool item is already self-signed. make a new spool item."
        return $EX_USAGE
    }

    ## acme-tiny doesn't work with the restrictive umask (files in
    ## ACME_CHALLENGE_DIR are not made world-readable), thus temporarily
    ## lower the umask.
    touch "${spoolcrtfilepath}" # Note: create file with correct umask
    prevumask="$(umask)"
    umask 022
    python3 "${acme_tiny}" \
        --account-key "${accountkeyfilepath}" \
        --csr "${spoolcsrfilepath}" \
        --acme-dir "${ACME_CHALLENGE_DIR}" \
        > "${spoolcrtfilepath}"
        ## Note: redirects into already existing file in order to ensure proper
        ## permissions of file at every point
    ex=$?
    umask "$prevumask"
    [[ "$ex" -ne "$EX_OK" ]] && {
        logerror "could not get signed certificate from letsencrypt"
        return $EX_UNAVAILABLE
    }

    echo 1 > "${spoolitemdirpath}/isletsencrypt.bool"

    cat "${spoolcrtfilepath}" > "${TEMPFILEPATH}"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call 'cat'"
        return $EX_OSERR
    }
    openssl x509 \
        -in "${TEMPFILEPATH}" \
        -purpose \
        -text \
        > "${spoolcrtfilepath}"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call 'openssl'"
        return $EX_UNAVAILABLE
    }

    chmod ug+r "${spoolcrtfilepath}"

    return $EX_OK
}


##
## inspect the certificate, retrieve the signator's certificate, and so on
## up to the top certificate.
##
## include any thusly retrieves certificates in chain.pem in order, but
## exclude the top-most certificate.
## the top-most certificate (the CA certificate) is stored in ca.pem
##
function item_chain_retrieve() {
    [[ "${#FQDNS[@]}" -ge 1 ]] || {
        logerror "no FQDN given: ${#FQDNS[@]}"
        return $EX_USAGE
    }

    local identifier
    identifier="$(SpoolIdentifier "${FQDNS[@]}")"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call procedure 'SpoolIdentifier'"
        return $EX_SOFTWARE
    }

    loginfo "COMMAND for ${FQDNS[@]} (${identifier})"

    local spoolitemdirpath="${spooldir}/${identifier}"
    local spoolkeyfilepath="${spoolitemdirpath}/key.pem"
    local spoolcrtfilepath="${spoolitemdirpath}/crt.pem"
    local spoolchainfilepath="${spoolitemdirpath}/chain.pem"
    local spoolcrtchainfilepath="${spoolitemdirpath}/crt+chain.pem"
    local spoolcrtchainkeyfilepath="${spoolitemdirpath}/crt+chain+key.pem"
    local spoolcafilepath="${spoolitemdirpath}/ca.pem"
    ## check if crt already exists
    [[ -e "${spoolcrtfilepath}" ]] || {
        logerror "need crt: ${spoolcrtfilepath}"
        return $EX_USAGE
    }

    # cleanup just in case because we use pattern later
    rm -f "${spoolitemdirpath}/chain_"*".pem"

    logdebug "inspecting ${spoolcrtfilepath}"
    cat "${spoolcrtfilepath}" > "${TEMPFILEPATH}"
    local chaincount=0
    while true; do
        # determine issuer certificate uri and filename
        issuer_uri="$(
            openssl x509 -in "${TEMPFILEPATH}" -noout -text \
                | sed -n -re '/ +CA Issuers - URI:/s/[^:]*://p'
        )"

        # check if no issuer certificate => we have reached root
        [[ "${issuer_uri}" = "" ]] && {
            logdebug "inspected crt is root-crt"
            # store in ca.pem
            cat "${TEMPFILEPATH}" > "${spoolcafilepath}"
            # and end the loop
            break
        }

        # store in chainfile
        # (chain_0.pem will be crt.pem, chain_1.pem it's parent, etc)
        local chainfilepath="${spoolitemdirpath}/chain_${chaincount}.pem"
        cat "${TEMPFILEPATH}" > "${chainfilepath}"

        # advance the chain counter
        ((chaincount++))

        # retrieve issuer cert
        logdebug "issuer of crt is: ${issuer_uri} at #${chaincount}"
        curl -s "${issuer_uri}" > "${TEMPFILEPATH}"

        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not download issuer certificate from \
                '${issuer_uri}'"
            return $EX_UNAVAILABLE
        }

        ## Note: the downloaded content might be PEM or DER encoded, and might
        ## provide the certificate in a x509 or pkcs7 container format
        ## The "solution" is to just try all possible combination until one
        ## seems to work

        local converted=0
        local convertedfilepath="$(mktemp)"
        exec 4<>"${convertedfilepath}"
        rm -f "${convertedfilepath}"
        convertedfilepath="/proc/self/fd/4"

        # try convert if x509 with DER:
        [[ converted -eq 0 ]] && \
        openssl x509 \
            -inform "DER" -in "${TEMPFILEPATH}" \
            -text > "${convertedfilepath}" 2>/dev/null
        [[ "$?" -eq "$EX_OK" ]] && {
            # it did work, use result
            cat "${convertedfilepath}" > "${TEMPFILEPATH}"
            converted=1
        }
        # else

        # try convert if pkcs7 with DER:
        [[ converted -eq 0 ]] && \
        openssl pkcs7 \
            -inform "DER" -in "${TEMPFILEPATH}" \
            -print_certs \
            -text > "${convertedfilepath}" 2>/dev/null
        [[ "$?" -eq "$EX_OK" ]] && {
            cat "${convertedfilepath}" > "${TEMPFILEPATH}"
            converted=1
        }
        # else

        # no conversion method left, give up
        [[ converted -ne 0 ]] || {
            logerror "could not convert downloaded file into x509 PEM: ${issuer_uri}"
            return $EX_UNAVAILABLE
        }
    done


    ## concat all chain_* files together (chain_0.pem is duplicate of crt.pem)
    rm -f "${spoolitemdirpath}/chain_0.pem"
    find "${spoolitemdirpath}" -maxdepth 1 -name "chain_*.pem" -print0 \
        | xargs -0 cat \
        > "${spoolchainfilepath}"
    cat "${spoolcrtfilepath}" "${spoolchainfilepath}" \
        > "${spoolcrtchainfilepath}"
    cat "${spoolcrtfilepath}" "${spoolchainfilepath}" "${spoolkeyfilepath}" \
        > "${spoolcrtchainkeyfilepath}"


    return $EX_OK
}


##
## if the spool item would result in an fsck-ok database item, commit item
## to database and activate it.
## otherwise return with error and leave previous state as is.
##
function item_activate() {
    [[ "${#FQDNS[@]}" -ge 1 ]] || {
        logerror "no FQDN given: ${#FQDNS[@]}"
        return $EX_USAGE
    }

    ## implementation:
    ##   copy the item into database
    ##   perform FsckDabsItem
    ##    - if pass: set symlinks
    ##               remove spool item
    ##    - if fail: remove copied files
    ##

    local identifier="$(SpoolIdentifier "${FQDNS[@]}")"
    local dateidentifier
    dateidentifier="$(DateIdentifier "${FQDNS[@]}")"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call procedure 'DateIdentifier'"
        return $EX_SOFTWARE
    }

    dabsitemdirpath="${databasedir}/items/${dateidentifier}"
    spoolitemdirpath="${spooldir}/${identifier}"

    [[ -d "${spoolitemdirpath}" ]] || {
        logerror "spool item not found: ${spoolitemdirpath}"
        return $EX_NOINPUT
    }

    mkdir "${dabsitemdirpath}"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call 'mkdir'"
        return $EX_UNAVAILABLE
    }

    cp --recursive \
        --no-dereference \
        --preserve=timestamps \
        --remove-destination \
        --target-directory "${dabsitemdirpath}" \
        "${spoolitemdirpath}/"*
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call 'cp'"
        rm -Rf "${dabsitemdirpath}"
        return $EX_UNAVAILABLE
    }

    FsckDabsItem "${dateidentifier}"
    case "$?" in
        $EX_OK) ;; # ok, go on
        $EX_NO)
            logerror "ROLLBACK: FsckDabsItem didn't succeed after copy"
            rm -Rf "${dabsitemdirpath}"
            return $EX_UNAVAILABLE
        ;;
        *)
            logerror "could not call procedure 'FsckDabsItem' on new database"\
                "item: ${dabsitemdirpath}"
            return $EX_SOFTWARE
        ;;
    esac

    # is let's encrypt?
    local isletsencrypt="0"
    local isletsencrypt_file="${dabsitemdirpath}/isletsencrypt.bool"
    [[ -r "$isletsencrypt_file" ]] && {
        isletsencrypt="$(<"$isletsencrypt_file")"
    }
    # is self-signed?
    local isselfsign="0"
    local isselfsign_file="${dabsitemdirpath}/isselfsign.bool"
    [[ -r "$isselfsign_file" ]] && {
        isselfsign="$(<"$isselfsign_file")"
    }

    # set the fqdn symlinks to the activated item
    local linktarget="../items/${dateidentifier}"
    for fqdn in "${FQDNS[@]}"; do
        local linkname="${databasedir}/latest-by-fqdn/${fqdn}"
        ln -snf -T "${linktarget}" "${linkname}"
        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not call 'ln', database may be inconsistent!"
            rm -Rf "${dabsitemdirpath}"
            return $EX_SOFTWARE
        }
    done

    # set the letsencrypt-fqdn symlinks to the activated item
    if [[ 0 -ne "$isletsencrypt" ]]; then
        local linktarget="../items/${dateidentifier}"
        for fqdn in "${FQDNS[@]}"; do
            local linkname="${databasedir}/latest-letsencrypt-by-fqdn/${fqdn}"
            ln -snf -T "${linktarget}" "${linkname}"
            [[ "$?" -eq "$EX_OK" ]] || {
                logerror "could not call 'ln', database may be inconsistent!"
                rm -Rf "${dabsitemdirpath}"
                return $EX_SOFTWARE
            }
        done
    fi

    # set the selfsigned-fqdn symlinks to the activated item
    if [[ 0 -ne "$isselfsign" ]]; then
        local linktarget="../items/${dateidentifier}"
        for fqdn in "${FQDNS[@]}"; do
            local linkname="${databasedir}/latest-selfsign-by-fqdn/${fqdn}"
            ln -snf -T "${linktarget}" "${linkname}"
            [[ "$?" -eq "$EX_OK" ]] || {
                logerror "could not call 'ln', database may be inconsistent!"
                rm -Rf "${dabsitemdirpath}"
                return $EX_SOFTWARE
            }
        done
    fi

    ## remove spool item
    rm -Rf "${spoolitemdirpath}"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call 'rm', database may be inconsistent!"
        return $EX_SOFTWARE
    }

    return $EX_OK
}


##
## mark the spool item as failed, commit it to the database
## (does not activate the item)
##
## TODO: the "commit" part of this procedure is a copy&paste from item_activate
##
function item_fail() {
    [[ "${#FQDNS[@]}" -ge 1 ]] || {
        logerror "no FQDN given: ${#FQDNS[@]}"
        return $EX_USAGE
    }

    local identifier="$(SpoolIdentifier "${FQDNS[@]}")"
    local dateidentifier
    dateidentifier="$(DateIdentifier "${FQDNS[@]}")"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call procedure 'DateIdentifier'"
        return $EX_SOFTWARE
    }

    logdebug "COMMAND for ${FQDNS[@]} (${identifier} => ${dateidentifier})"

    dabsitemdirpath="${databasedir}/items/${dateidentifier}"
    spoolitemdirpath="${spooldir}/${identifier}"

    [[ -d "${spoolitemdirpath}" ]] || {
        logerror "spool item not found: ${spoolitemdirpath}"
        return $EX_NOINPUT
    }

    ## mark as failed
    echo 1 > "${spoolitemdirpath}/isfailed.bool"

    ## commit
    mkdir "${dabsitemdirpath}"
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call 'mkdir'"
        return $EX_UNAVAILABLE
    }

    cp --recursive \
        --no-dereference \
        --preserve=timestamps \
        --remove-destination \
        --target-directory "${dabsitemdirpath}" \
        "${spoolitemdirpath}/"*
    [[ "$?" -eq "$EX_OK" ]] || {
        logerror "could not call 'cp'"
        rm -Rf "${dabsitemdirpath}"
        return $EX_UNAVAILABLE
    }

    ## remove spool item
    rm -Rf "${spoolitemdirpath}"

    return $EX_OK
}


##
## see printhelp
##
function automatic_letsencrypt() {
    [[ "${#FQDNS[@]}" -le 0 ]] && {
        logerror "no domainnames given: ${#FQDNS[@]}"
        return $EX_USAGE
    }


    local identifier
    identifier="$(SpoolIdentifier "${FQDNS[@]}")"

    logdebug "CHECK '${FQDNS[@]}' (${identifier})"


    ## 1. determine if renew for this FQDN+SANs is needed

    # holds the result, whether a database item for all FQDN+SANs exists, are
    # without error(s), are not expired and will not expire soon.
    local havegooddabs=1

    for fqdn in "${FQDNS[@]}"; do
        local linkname
        linkname="${databasedir}/latest-by-fqdn/${fqdn}"
        logdebug "inspecting ${fqdn} at ${linkname}"

        local linktarget
        ((havegooddabs==1)) && {
            linktarget=$(readlink -n "${linkname}")
            if [[ "$?" -eq "${EX_OK}" ]]; then
                logdebug "found ${fqdn} pointing to ${linktarget}"
            else
                loginfo "couldn't readlink no certificate for ${fqdn} exists yet"
                havegooddabs=0
            fi
        }

        ((havegooddabs==1)) && {
            local dateidentifier
            dateidentifier="$(basename "${linktarget}")"
            if [[ "$?" -eq "${EX_OK}" ]]; then
                logdebug "resolved ${fqdn} => ${dateidentifier}"
                ShouldRenewDabsItem "${dateidentifier}"
                case "$?" in
                    $EX_OK) ;; # ok, go on
                    $EX_NO)
                        loginfo "UNCLEAN ${fqdn} (\"${dateidentifier}\")"
                        havegooddabs=0
                    ;;
                    *)
                        logerror "could not call procedure 'ShouldRenewDabsItem' on database"\
                            "item: ${dateidentifier}"
                        return $EX_SOFTWARE
                    ;;
                esac
            else
                logwarn "could not call 'basename', assuming bad database item:"\
                    "${linktarget}"
                havegooddabs=0
            fi


            # check if the, otherwise good, database item is a self-signed
            # certificate. if yes, still need to retrieve a let's encrypt
            # certificate
            ((havegooddabs==1)) && {
                local dabsitemdirpath="${databasedir}/items/${dateidentifier}"
                local dabsisselfsignfilepath="${dabsitemdirpath}/isselfsign.bool"
                if [[ -e "${dabsisselfsignfilepath}" && "$(<"${dabsisselfsignfilepath}")" -ne 0  ]]; then
                    loginfo "IS SELF SIGN: ${fqdn}/${dateidentifier}"
                    havegooddabs=0
                else
                    logdebug "not self-signed: ${fqdn}/${dateidentifier}"
                fi
            }
        }
    done


    ## 2a. if all database items for all checked fqdns are still good, then
    ## return successfully
    local spoolitemdirpath="${spooldir}/${identifier}"
    ((havegooddabs==1)) && {
        logdebug "all good, nothing to do for: ${FQDNS[@]}"

        # if a spool item exists for this identifier, then fail it
        [[ -e "${spoolitemdirpath}" ]] && {
            item_fail
            local ex="$?"
            [[ "$ex" -eq "$EX_OK" ]] || {
                logerror "could not call procedure 'item_fail'"
                return "$ex"
            }
        }

        return $EX_OK
    }


    ## 2b. else, need to perform the steps to retrieve a new let's encrypt
    ## certificate


    ## 3. create account key if missing
    logdebug "CHECKING ACCOUNT KEY"
    [[ -f "${letsencryptaccountdir}/current/account_key.pem" ]] || {
        # create new account key
        new_letsencrypt_account
        [[ "$?" -ne "${EX_OK}" ]] && {
            logerror "could not create account key"
            return $EX_UNAVAILABLE
        }
    }
    local accountkeyfilepath="${letsencryptaccountdir}/current/account_key.pem"

    ## 4. create spool item if not yet exists
    [[ -e "${spoolitemdirpath}" ]] || {
        new_item
        [[ "$?" -ne "${EX_OK}" ]] && {
            logerror "could not create spool item for '${FQDNS[@]}'"
            return $EX_UNAVAILABLE
        }
    }

    # if spool item does not exist at this point, this program has a bug
    [[ -d "${spoolitemdirpath}" ]] || {
        logerror "spool item directory does not exist:"\
            "${spoolitemdirpath}"
        return $EX_SOFTWARE
    }

    ## 5. check if key exists, else create it
    [[ -e "${spoolitemdirpath}/key.pem" ]] || {
        # does not exist, create key
        item_new_key
        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not create key: ${spoolitemdirpath}/key.pem"
            return $EX_UNAVAILABLE
        }
    }

    ## 6. check if csr exists, else create it
    [[ -e "${spoolitemdirpath}/csr.pem" ]] || {
        # does not exist, create csr
        item_new_csr
        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not create csr: ${spoolitemdirpath}/csr.pem"
            return $EX_UNAVAILABLE
        }
    }

    ## 7. check if crt exists, else request if from letsencrypt
    [[ -e "${spoolitemdirpath}/crt.pem" ]] || {
        # does not exist, retrieve crt
        item_submit_csr_to_letsencrypt
        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not retrieve crt: ${spoolitemdirpath}/crt.pem"
            return $EX_UNAVAILABLE
        }
    }

    ## 8. ensure the chain is retrieved
    [[ -e "${spoolitemdirpath}/chain.pem" ]] || {
        # does not exist, retrieve chain
        item_chain_retrieve
        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not retrieve chain: ${spoolitemdirpath}/chain.pem"
            return $EX_UNAVAILABLE
        }
    }


    ## 9. check if this really is a let's encrypt item, then activate else fail

    # is let's encrypt?
    local isletsencrypt="0"
    local isletsencrypt_file="${spoolitemdirpath}/isletsencrypt.bool"
    [[ -r "$isletsencrypt_file" ]] && {
        isletsencrypt="$(<"$isletsencrypt_file")"
    }

    ## 9a. try to activate item
    if [[ "$isletsencrypt" -eq "1" ]]; then
        item_activate
        if [[ "$?" -eq "$EX_OK" ]]; then
            logdebug "SUCCESS: ${FQDNS[@]}"
            return $EX_OK
        else
            logdebug "FAIL: ${FQDNS[@]}"
            item_fail
            return $EX_FAIL
        fi
    ## 9b. fail non-self-signed item
    else
        item_fail
        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not call procedure 'item_fail'"
            return $EX_UNAVAILABLE
        }
    fi


    return $EX_OK
}


##
## see printhelp
##
function automatic_selfsign() {
    [[ "${#FQDNS[@]}" -le 0 ]] && {
        logerror "no domainnames given: ${#FQDNS[@]}"
        return $EX_USAGE
    }


    local identifier
    identifier="$(SpoolIdentifier "${FQDNS[@]}")"

    logdebug "CHECK '${FQDNS[@]}' (${identifier})"


    ## 1. determine if renew for this FQDN+SANs is needed

    # holds the result, whether a database item for all FQDN+SANs exists, are
    # without error(s), are not expired and will not expire soon.
    local havegooddabs=1

    for fqdn in "${FQDNS[@]}"; do
        local linkname
        linkname="${databasedir}/latest-selfsign-by-fqdn/${fqdn}"
        logdebug "inspecting ${fqdn} at ${linkname}"

        local linktarget
        ((havegooddabs==1)) && {
            linktarget=$(readlink -n "${linkname}")
            if [[ "$?" -eq "${EX_OK}" ]]; then
                logdebug "found ${fqdn} pointing to ${linktarget}"
            else
                loginfo "couldn't readlink no self-signed certificate for ${fqdn} exists yet"
                havegooddabs=0
            fi
        }

        ((havegooddabs==1)) && {
            local dateidentifier
            dateidentifier="$(basename "${linktarget}")"
            if [[ "$?" -eq "${EX_OK}" ]]; then
                logdebug "resolved ${fqdn} => ${dateidentifier}"
                ShouldRenewDabsItem "${dateidentifier}"
                case "$?" in
                    $EX_OK) ;; # ok, go on
                    $EX_NO)
                        loginfo "UNCLEAN ${fqdn} (\"${dateidentifier}\")"
                        havegooddabs=0
                    ;;
                    *)
                        logerror "could not call procedure 'ShouldRenewDabsItem' on database"\
                            "item: ${dateidentifier}"
                        return $EX_SOFTWARE
                    ;;
                esac
            else
                logwarn "could not call 'basename', assuming bad database item:"\
                    "${linktarget}"
                havegooddabs=0
            fi
        }
    done


    ## 2a. if all database items for all checked fqdns are still good, then
    ## return successfully
    local spoolitemdirpath="${spooldir}/${identifier}"
    ((havegooddabs==1)) && {
        logdebug "all good, nothing to do for: ${FQDNS[@]}"

        # if a spool item exists for this identifier, then fail it
        [[ -e "${spoolitemdirpath}" ]] && {
            item_fail
            local ex="$?"
            [[ "$ex" -eq "$EX_OK" ]] || {
                logerror "could not call procedure 'item_fail'"
                return "$ex"
            }
        }

        return $EX_OK
    }


    ## 2b. else, need to perform the steps to create a new self-signed
    ## certificate


    ## 3. create spool item if not yet exists
    [[ -e "${spoolitemdirpath}" ]] || {
        new_item
        [[ "$?" -ne "${EX_OK}" ]] && {
            logerror "could not create spool item for '${FQDNS[@]}'"
            return $EX_UNAVAILABLE
        }
    }

    # if spool item does not exist at this point, this program has a bug
    [[ -d "${spoolitemdirpath}" ]] || {
        logerror "spool item directory does not exist:"\
            "${spoolitemdirpath}"
        return $EX_SOFTWARE
    }


    ## 4. check if key exists, else create it
    [[ -e "${spoolitemdirpath}/key.pem" ]] || {
        # does not exist, create key
        item_new_key
        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not create key: ${spoolitemdirpath}/key.pem"
            return $EX_UNAVAILABLE
        }
    }

    ## 5. check if csr exists, else create it
    [[ -e "${spoolitemdirpath}/csr.pem" ]] || {
        # does not exist, create csr
        item_new_csr
        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not create csr: ${spoolitemdirpath}/csr.pem"
            return $EX_UNAVAILABLE
        }
    }

    ## 6. check if crt exists, else create a self-signed
    [[ -e "${spoolitemdirpath}/crt.pem" ]] || {
        # does not exist, retrieve crt
        item_self_sign
        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not retrieve crt: ${spoolitemdirpath}/crt.pem"
            return $EX_UNAVAILABLE
        }
    }

    ## 7. ensure the chain is retrieved
    [[ -e "${spoolitemdirpath}/chain.pem" ]] || {
        # does not exist, retrieve chain
        item_chain_retrieve
        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not retrieve chain: ${spoolitemdirpath}/chain.pem"
            return $EX_UNAVAILABLE
        }
    }

    ## 8. check if this really is a self-signed item, then activate else fail

    # is self-signed?
    local isselfsign="0"
    local isselfsign_file="${spoolitemdirpath}/isselfsign.bool"
    [[ -r "$isselfsign_file" ]] && {
        isselfsign="$(<"$isselfsign_file")"
    }

    ## 8a. try to activate item
    if [[ "$isselfsign" -eq "1" ]]; then
        item_activate
        if [[ "$?" -eq "$EX_OK" ]]; then
            logdebug "SUCCESS: ${FQDNS[@]}"
            return $EX_OK
        else
            logdebug "FAIL: ${FQDNS[@]}"
            item_fail
            return $EX_FAIL
        fi
    ## 8b. fail non-self-signed item
    else
        item_fail
        [[ "$?" -eq "$EX_OK" ]] || {
            logerror "could not call procedure 'item_fail'"
            return $EX_UNAVAILABLE
        }
    fi


    return $EX_OK
}





###############################################################################
#################################### MAIN #####################################
###############################################################################

function main() {
    # this file is touched in order to avoid a warning by openssl on the
    # first run.
    touch "${HOME}/.rnd"

    # if OPT_ALL_DOMAINS, read all lines from etc/domains.list into $FQDNS[]
    if [[ "$OPT_ALL_DOMAINS" -eq 1 ]];
    then
        [[ -r "${DOMAINSLIST_FILEPATH}" ]] || {
            logerror "cannot read ${DOMAINSLIST_FILEPATH}"
            return $EX_NOINPUT
        }

        local line
        while read line; do
            # skip comments
            [[ "$line" =~ ^\ *# ]] && continue;
            # skip empty lines
            [[ "$line" =~ ^\ *$ ]] && continue;
            # expand the line into fqdns
            # it's simple, because each line is a space-seperated list of
            # fqdns
            FQDNS=($line)
            # execute command in subshell
            local cmd
            for cmd in "${CMDS[@]}"; do
                "${MYNAME}" --loglevel "${LOG_LEVEL}" "--${cmd}" "${FQDNS[@]}"
            done
        done <"${DOMAINSLIST_FILEPATH}"
    else
        # FQDNS already expanded/set by parse_cmdline
        # execute command
        local cmd
        for cmd in "${CMDS[@]}"; do
            "${cmd}"
        done
    fi
    return $?
}


## get configuration
[[ -r "${CONFIG_FILEPATH}" ]] || {
    logerror "cannot read config file: ${CONFIG_FILEPATH}"
    exit $EX_CONFIG
}
source "${CONFIG_FILEPATH}"


## parse commandline, if successful, call main
## this scripts exit code will be the exit code of either
## parse_cmdline (if some error while parsing), or
## of main (success or fail)
parse_cmdline "$@" && main
