#!/bin/sh # ############################################################################### # # dyntls.sh # # Automated Let's Encrypt Certificate Management Script # # This script automates issuing, updating, and managing Let's Encrypt certificates # using a local PKI structure, integrated service mappings, and secure defaults. # It supports service reloads and PKI operations via 'systemd' and 'openssl', reads # configuration from a vars file, and enforces strong file permissions—suitable # for production and testing environments. # # Features: # - Automated certificate issuance and renewal (incl. SAN, wildcard) # - Full PKI structure with configurable paths # - Service mapping for cert/key deployment and reloads # - Support for pre/post command hooks # - Configurable ACME client program via vars # - POSIX-compatible operation for cron and systemd integration # # Authors: Stephan Düsterhaupt # Ivo Noack aka Insonic # # Copyright (c) 2018-2026 CB-601 - the open tec Elevator # License: MIT # # Project Home: https://dev.town-square.de/cb601/dyntls # ############################################################################### # MIT License # # Copyright (c) 2025 CB-601 - the open tec Elevator # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense and/or sell # copies of the Software and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # SDuesterhaupt: 2025-09-14 - Show usage/help of the script and commands # # Prints general usage information and available commands to stdout. # Intended to guide user on how to run the script and list supported operations. # # No input parameters. # Outputs text to stdout. _usage() { _log "Enter the function '_usage()'..." 1 # command help: # init-pki # update [ cmd-opts ] # gen-req [ cmd-opts ] printf '%s\n' \ "dynTLS usage and overview" \ "" \ "USAGE: dyntls [options] COMMAND [command-options]" \ "" \ "A list of commands is shown below. To get detailed usage and help for a command, run:" \ " dyntls help COMMAND" \ "" \ "For a listing of options that can be supplied before the command, use:" \ " dyntls help options" \ "" \ "Commands:" \ " init-pki" \ " add-cert [ cmd-opts ]" \ " check-cert [ cmd-opts ]" \ " remove-cert [ cmd-opts ]" \ " update-cert [ cmd-opts ]" \ "" # Verzeichnisse prüfen err_source="Not defined: vars autodetect failed and no value provided" work_dir=${DYNTLS:-$err_source} pki_dir=${DYNTLS_PKI:-$err_source} printf '%s\n' \ "DIRECTORY STATUS (commands would take effect on these locations)" \ " DYNTLS: $work_dir" \ " PKI: $pki_dir" \ "" _log "Leave the function '_usage()'..." 1 return 0 } #=> usage() # SDuesterhaupt: 2025-09-14 - Show help for a specific command or options # # When called with no arguments, calls _usage(). # Otherwise prints detailed help text for a given command. # # @param1: Command name or special keywords like "options". # Outputs help text to stdout. _cmd_help() { text="" opts="" case "$1" in # init-pki|clean-all) #text=" init-pki) text="init-pki [ cmd-opts]\n Removes & re-initializes the PKI dir for a clean PKI" ;; add-hostname) text="add-hostname [ cmd-opts]\n Add a hostname to a DNS zone" ;; check-hostname) text="check-hostname [ cmd-opts]\n Check a hostname of a DNS zone" ;; remove-hostname) text="remove-hostname [ cmd-opts]\n Remove a hostname from a DNS zone" ;; update) text="update [ cmd-opts]\n Update a DNS zone" ;; options) _opt_usage return 0 ;; "") _usage return 0 ;; *) text="Unknown command: '$1' (try without command for a list of commands)" ;; esac # display the help text [ -n "$text" ] && printf '%b\n' "$text" [ -n "$opts" ] && { printf '\n%s\n' "cmd-opts is an optional set of command options from this list:" printf '%s\n' "$opts" } return 0 } #=> _cmd_help() # SDuesterhaupt: 2025-09-14 - Print global option flags and usage # # Prints description of global command line options recognized by the script. # # No input parameters. # Outputs text to stdout. _opt_usage() { printf '%s\n' \ "dynTLS Global Option Flags" \ "" \ "The following options may be provided before the command. Options specified" \ "at runtime override env-vars and any 'vars' file in use. Unless noted," \ "non-empty values to options are mandatory." \ "" \ "General options:" \ " --vars=FILE define a specific 'vars' file to use for dynTLS config" \ "" \ "Add hostname options (create an account in the database):" \ " --hostname=ARG set a hostname" \ "" \ "Update options:" \ " --zone=ARG set DNS zone ARG for update (example365.tld)" \ "" return 0 } #=> _opt_usage() # SDuesterhaupt: 2025-09-14 - Exit script with fatal error message # # Prints an error message to stderr, logs it if applicable, then exits script with given error code. # # @param1: Error message string to print. # @param2: Exit code integer (default 1). # No output (exits). _die() { msg=$1 code=${2:-1} # Console + stderr Meldung printf '\n%s\n%s\n' "dynTLS error:" "$msg" >&2 # Logging, falls gewünscht #[ "$code" -ne 0 ] && _log "$msg" 4 exit "$code" } #=> _die() # SDuesterhaupt: 2025-09-14 - Print informational notice to stdout # # Prints a message unless batch mode is enabled. # # @param1: Message string to print. # Outputs message to stdout conditionally. notice() { # gibt nur etwas aus, wenn nicht im Batchmodus if [ -z "$DYNTLS_BATCH" ]; then printf '\n%s\n' "$1" fi return 0 } #=> notice() # SDuesterhaupt: 2025-09-14 - Create a secure temporary file/directory # # Wrapper for mktemp to create temporary files or directories in the session temp directory. # Ensures that DYNTLS_TEMP_DIR_session is initialized and usable. # # No parameters. # Outputs path of created temporary file/directory to stdout. # Returns 0 on success or 1 on failure. dyntls_mktemp() { [ -n "$DYNTLS_TEMP_DIR_session" ] || _die "DYNTLS_TEMP_DIR_session not initialized!" 1 [ -d "$DYNTLS_TEMP_DIR_session" ] || mkdir -p "$DYNTLS_TEMP_DIR_session" \ || _die "Could not create temporary directory '$DYNTLS_TEMP_DIR_session'" 1 template="$DYNTLS_TEMP_DIR_session/tmp.XXXXXX" tempfile=$(mktemp "$template") || return 1 # Workaround für win32 mktemp if [ "$template" = "$tempfile" ]; then tempfile=$(mktemp -du "$tempfile") || return 1 : >"$tempfile" || return 1 fi printf '%s\n' "$tempfile" return 0 } #=> dyntls_mktemp # SDuesterhaupt: 2025-09-14 - Clean up temporary files and restore terminal state on exit # # Removes temporary session directory if it exists and resets terminal echo. # # No parameters or output. cleanup() { # Entferne temporäres Sitzungsverzeichnis falls vorhanden [ -n "$DYNTLS_TEMP_DIR_session" ] && rm -rf "$DYNTLS_TEMP_DIR_session" # Terminal-Zustand wiederherstellen (stty echo 2>/dev/null) || { (set -o echo 2>/dev/null) && set -o echo; } printf '\n' return 0 } #=> cleanup() # SDuesterhaupt: 2025-09-14 - Verify essential DYNTLS environment variables are defined # # Checks the presence of critical env vars for PKI and HTTP directories. # Exits fatally if any required variable is undefined. # # No input parameters. # No output. _vars_source_check() { _log "Enter the function '_vars_source_check()'..." 1 [ -n "$DYNTLS_PKI" ] || _die "DYNTLS_PKI env-var undefined" 1 [ -n "$DYNTLS_PKI_HTTP_DIR" ] || _die "DYNTLS_PKI_HTTP_DIR env-var undefined" 1 [ -n "$DYNTLS_PKI_HTTP_CERT_DIR" ] || _die "DYNTLS_PKI_HTTP_CERT_DIR env-var undefined" 1 [ -n "$DYNTLS_PKI_HTTP_KEY_DIR" ] || _die "DYNTLS_PKI_HTTP_KEY_DIR env-var undefined" 1 [ -n "$DYNTLS_HTTPD_DEFAULT_DIR" ] || _die "DYNTLS_HTTPD_DEFAULT_DIR env-var undefined" 1 [ -n "$DYNTLS_ENCRYPT_TOKEN_DIR" ] || _die "DYNTLS_ENCRYPT_TOKEN_DIR env-var undefined" 1 _log "Leave the function '_vars_source_check()'..." 1 return 0 } #=> _vars_source_check() # SDuesterhaupt: 2025-09-14 - Verify PKI directory structure and required files exist # # Checks existence of necessary PKI directories and server base key. # Calls _vars_source_check internally. # Exits fatally if any required directory or file is missing. # # No input parameters. # No output. _verify_pki_init() { _log "Enter the function '_verify_pki_init()'..." 1 help_note="Run dyntls without commands for usage and command help." _vars_source_check [ -d "$DYNTLS_PKI" ] || _die "DYNTLS_PKI does not exist. $help_note" 1 [ -d "$DYNTLS_PKI_HTTP_DIR" ] || _die "DYNTLS_PKI_HTTP_DIR missing. $help_note" 1 [ -d "$DYNTLS_PKI_HTTP_CERT_DIR" ] || _die "DYNTLS_PKI_HTTP_CERT_DIR missing. $help_note" 1 [ -d "$DYNTLS_PKI_HTTP_KEY_DIR" ] || _die "DYNTLS_PKI_HTTP_KEY_DIR missing. $help_note" 1 [ -d "$DYNTLS_HTTPD_DEFAULT_DIR" ] || _die "DYNTLS_HTTPD_DEFAULT_DIR missing. $help_note" 1 [ -d "$DYNTLS_ENCRYPT_TOKEN_DIR" ] || _die "DYNTLS_ENCRYPT_TOKEN_DIR missing. $help_note" 1 [ -f "$DYNTLS_PKI_SERVER_BASEKEY" ] || _die "DYNTLS_PKI_SERVER_BASEKEY missing. $help_note" 1 _log "Leave the function '_verify_pki_init()'..." 1 return 0 } #=> _verify_pki_init() # SDuesterhaupt: 2025-09-14 - Initialize PKI directory structure and keys # # Creates PKI cert and key directories if missing. # Generates base server key if missing and key linking is enabled. # Sets ownership and permissions on directories and files. # # No input parameters. # Outputs informational messages and errors. _init_pki() { _log "Enter the function '_init_pki()'..." 1 [ -d "$DYNTLS_PKI_HTTP_CERT_DIR" ] || mkdir -p "$DYNTLS_PKI_HTTP_CERT_DIR" \ || _die "Failed to create $DYNTLS_PKI_HTTP_CERT_DIR" 1 [ -d "$DYNTLS_PKI_HTTP_KEY_DIR" ] || mkdir -p "$DYNTLS_PKI_HTTP_KEY_DIR" \ || _die "Failed to create $DYNTLS_PKI_HTTP_KEY_DIR" 1 if [ ! -f "$DYNTLS_PKI_SERVER_BASEKEY" ] && [ "$DYNTLS_PKI_KEY_LNS" -ne 0 ]; then _log "Create base server key, length $DYNTLS_PKI_KEY_SIZE bit" 1 #MyLEError=$(eval "$DYNTLS_OPENSSL genrsa -out $DYNTLS_PKI_SERVER_BASEKEY $DYNTLS_PKI_KEY_SIZE 2>&1") "$DYNTLS_OPENSSL" genrsa -out "$DYNTLS_PKI_SERVER_BASEKEY" "$DYNTLS_PKI_KEY_SIZE" 2>/dev/null \ || _die "Failed to generate base server key" 1 fi find "$DYNTLS_HTTPD_DEFAULT_DIR" -type d -exec chown "$DYNTLS_HTTPD_DEFAULT_OWNER" {} \; -exec chmod 755 {} \; find "$DYNTLS_PKI_HTTP_CERT_DIR" -type f -exec chmod 644 {} \; find "$DYNTLS_PKI_HTTP_KEY_DIR" -type f -exec chmod 440 {} \; [ -d "$DYNTLS_ENCRYPT_TOKEN_DIR" ] || mkdir -p "$DYNTLS_ENCRYPT_TOKEN_DIR" \ || _die "Failed to create $DYNTLS_ENCRYPT_TOKEN_DIR" 1 notice "init-pki complete; you may now create a certificate. Your PKI dirs are: $DYNTLS_PKI_HTTP_CERT_DIR, $DYNTLS_PKI_HTTP_KEY_DIR, $DYNTLS_ENCRYPT_TOKEN_DIR" _log "Leave the function '_init_pki()'..." 1 return 0 } #=> _init_pki() # SDuesterhaupt: 2025-09-14 - Initialize log directory for dynTLS logs # # Checks for the log directory existence; creates it if missing. # Exits fatally on failure. # # No input parameters. # Outputs informational messages and errors. _init_log() { if [ ! -d "$DYNTLS_LOG_DIR" ]; then printf "The log directory '%s' doesn't exist. Try to create it...\n" "$DYNTLS_LOG_DIR" if mkdir -p "$DYNTLS_LOG_DIR" 2>/dev/null; then printf "Log directory '%s' created.\n" "$DYNTLS_LOG_DIR" else _die "Could not create log directory '$DYNTLS_LOG_DIR'" 1 fi fi return 0 } #=> _init_log # SDuesterhaupt: 2025-09-14 - Load and set configuration variables for dynTLS # # Loads configuration variables from a 'vars' file if defined, falling back to defaults. # Supports multiple possible locations for the vars file, with overriding behavior. # Initializes many DYNTLS_* environment variables with defaults if not already set. # This ensures consistent environment setup for the remainder of the script. # # No input parameters. # Sets and exports multiple DYNTLS_* environment variables. # Outputs informational notice if vars file was loaded. _vars_setup() { prog_file=$0 prog_dir=$(dirname "$(readlink -f "$prog_file" 2>/dev/null || printf '%s' "$prog_file")") vars="" [ -f "$DYNTLS_VARS_FILE" ] && vars=$DYNTLS_VARS_FILE [ -z "$vars" ] && [ -n "$DYNTLS" ] && [ -f "$DYNTLS/vars" ] && vars=$DYNTLS/vars [ -z "$vars" ] && [ -f "$prog_dir/vars" ] && vars=$prog_dir/vars if [ -z "$DYNTLS_NO_VARS" ] && [ -n "$vars" ]; then DYNTLS_CALLER=1 . "$vars" notice "Note: using dynTLS configuration from: $vars" fi # Set defaults, preferring existing env-vars if present set_var DYNTLS "$prog_dir" set_var DYNTLS_LE_PROGRAM "letsencrypt_master.sh" set_var DYNTLS_OPENSSL openssl set_var DYNTLS_TMP "$DYNTLS/tmp" #set_var DYNTLS_DN cn_only #set_var DYNTLS_REQ_COUNTRY "US" #set_var DYNTLS_REQ_PROVINCE "California" #set_var DYNTLS_REQ_CITY "San Francisco" #set_var DYNTLS_REQ_ORG "Copyleft Certificate Co" #set_var DYNTLS_REQ_EMAIL me@example.net #set_var DYNTLS_REQ_OU "My Organizational Unit" set_var DYNTLS_PKI "/etc/pki" set_var DYNTLS_PKI_KEY_ALGO rsa set_var DYNTLS_PKI_KEY_SIZE 2048 set_var DYNTLS_PKI_KEY_LNS 0 set_var DYNTLS_PKI_KEY_CURVE secp384r1 set_var DYNTLS_PKI_HTTP_DIR "$DYNTLS_PKI/httpd" set_var DYNTLS_PKI_HTTP_CERT_DIR "$DYNTLS_PKI_HTTP_DIR/certs" set_var DYNTLS_PKI_HTTP_CERT_BACKUP_DIR "$DYNTLS_PKI_HTTP_CERT_DIR/backup" set_var DYNTLS_PKI_HTTP_KEY_DIR "$DYNTLS_PKI_HTTP_DIR/private" set_var DYNTLS_PKI_CERT_SUFFIX "cert.pem" set_var DYNTLS_PKI_FULLCHAIN_SUFFIX "fullchain.pem" set_var DYNTLS_PKI_KEY_SUFFIX "key.pem" set_var DYNTLS_PKI_LECA_CHAIN_FILE "LE_CA.chain.pem" set_var DYNTLS_PKI_LECA_CHAIN "$DYNTLS_PKI_HTTP_CERT_DIR/$DYNTLS_PKI_LECA_CHAIN_FILE" set_var DYNTLS_PKI_LECA_R12_CHAIN_FILE "LE_CA-R12.chain.pem" set_var DYNTLS_PKI_LECA_R12_CHAIN "$DYNTLS_PKI_HTTP_CERT_DIR/$DYNTLS_PKI_LECA_R12_CHAIN_FILE" set_var DYNTLS_PKI_LECA_R13_CHAIN_FILE "LE_CA-R13.chain.pem" set_var DYNTLS_PKI_LECA_R13_CHAIN "$DYNTLS_PKI_HTTP_CERT_DIR/$DYNTLS_PKI_LECA_R13_CHAIN_FILE" set_var DYNTLS_PKI_CERT_EXPIRE 30 # Let's Encrypt default: 30 days set_var DYNTLS_PKI_KEY_FORCE_RENEW 0 set_var DYNTLS_TEMP_DIR "$DYNTLS_TMP" set_var DYNTLS_BACKUP_EXPIRATION 0 set_var DYNTLS_LOG_DIR "/var/log/dyntls" set_var DYNTLS_LOG_FILE "$DYNTLS_LOG_DIR/dyntls.log" set_var DYNTLS_LOG_LEVEL "3" set_var DYNTLS_ENCRYPT_ACCOUNTKEY "$DYNTLS/private/letsencrypt_account.key" set_var DYNTLS_PKI_SERVER_BASEKEY_FILE "base.$DYNTLS_PKI_KEY_SUFFIX" set_var DYNTLS_PKI_SERVER_BASEKEY "$DYNTLS_PKI_HTTP_KEY_DIR/$DYNTLS_PKI_SERVER_BASEKEY_FILE" set_var DYNTLS_HTTPD_DEFAULT_DIR "/var/www/public_html/default" set_var DYNTLS_ENCRYPT_TOKEN_DIR "$DYNTLS_HTTPD_DEFAULT_DIR/.well-known/acme-challenge" set_var DYNTLS_DNS_OPTIONS "" set_var DYNTLS_DNS_SERVER "root-dns.example365.tld" set_var DYNTLS_DNS_TSIG "tsig.key" set_var DYNTLS_DNS_ZONE "" set_var DYNTLS_HTTPD_DEFAULT_OWNER "apache." set_var DYNTLS_RELOAD_WEBSERVER "false" set_var DYNTLS_SEND_MAIL "false" set_list DYNTLS_DOMAIN_LIST "example365.tld:sub1.example365.tld:sub2.example365.tld" "1" set_list DYNTLS_DOMAINSERVICE_LIST "mail02.example365.tld:postfix:root.root:444:postfix:1:restart:Postfix" "1" set_var DYNTLS_PRODUCTIVE 0 set_list DYNTLS_CMD_PRE_LIST "" set_list DYNTLS_CMD_POST_LIST "" # Assign value to $DYNTLS_TEMP_DIR_session and work around Windows mktemp bug when parent dir is missing if [ -z "$DYNTLS_TEMP_DIR_session" ]; then if [ -d "$DYNTLS_TEMP_DIR" ]; then DYNTLS_TEMP_DIR_session="$(mktemp -du "$DYNTLS_TEMP_DIR/dyn-tls-$$.XXXXXX")" else # If the directory does not exist then we have not run init-pki mkdir -p "$DYNTLS_TEMP_DIR" || _die "Cannot create $DYNTLS_TEMP_DIR (permission?)" "1" DYNTLS_TEMP_DIR_session="$(mktemp -du "$DYNTLS_TEMP_DIR/dyn-tls-$$.XXXXXX")" rm -rf "$DYNTLS_TEMP_DIR" fi fi return 0 } # _vars_setup() # SDuesterhaupt: 2025-09-14 - Sets environment variables with defaults if not defined # # @param1: Name of environment variable to set. # @param2...: Default value(s) to assign if variable is unset or empty. # Exports the variable and sets value accordingly. # # No output. set_var() { var=$1 shift value=$* eval "${var}=\${$var:-$value}" export "$var" } #=> set_var() # SDuesterhaupt: 2025-09-14 - Set a list-style environment variable with concatenation # # Adds new values to an existing colon-separated list variable or sets it if empty. # @param1: Name of list variable. # @param2: New value(s) to add. # @param3: Default flag (optional). # Exports updated list variable. # # No output. set_list() { list=$1 value=$2 default=$3 current=$(eval "printf '%s' \${$list}") if [ -n "$current" ]; then [ -z "$default" ] && eval "$list=\"$current&$value\"" else eval "$list=\"$value\"" fi export "$list" } #=> set_list() # SDuesterhaupt: 2025-09-14 - Log messages with severity and optionally print to console/stderr # # 0 -> OFF (silent mode) # 1 -> DEBUG # 2 -> INFO # 3 -> WARN # 4 -> ERROR # 5 -> CRITICAL # # @param1: Message string to log. # @param2: Severity level (0-5) where higher is more severe. # Writes message with timestamp to log file. # Prints to stderr if level >= 2. _log() { msg=$1 level=$2 # Optional domainset prefix to group log lines belonging to the same domain set ds_prefix="" if [ -n "$DYNTLS_DOMAINSET_ID" ]; then ds_prefix="[DS:${DYNTLS_DOMAINSET_ID}]" fi # Map numeric levels to human-readable tags # 0 -> OFF (silent mode) # 1 -> DEBUG # 2 -> INFO # 3 -> WARN # 4 -> ERROR # 5 -> CRITICAL set -- "" "DEBUG" "INFO" "WARNING" "ERROR" "CRITICAL" shift # now: $1=DEBUG, $2=INFO, ... # Determine the level name only for levels >= 1 level_name=$([ "$level" -ge 1 ] && eval "printf '%s' \${$level}") # Write to log file if level is high enough and message is not empty # Desired total width for "[LEVEL]" including brackets, e.g. 9 chars # Example: # "[INFO]" -> len 6 -> 3 spaces # "[DEBUG]" -> len 7 -> 2 spaces level_tag="[$level_name]" level_tag_len=${#level_tag} pad_width=9 pad_count=$(( pad_width - level_tag_len )) [ "$pad_count" -lt 1 ] && pad_count=1 pad=$(printf '%*s' "$pad_count" '') if [ "$level" -ge "$DYNTLS_LOG_LEVEL" ] && [ -n "$msg" ]; then ts=$(date '+%Y-%m-%d %H:%M:%S') # Example: # 2025-12-06 11:21:00 dyntls [DS:251206-1a2b3c4d]: [INFO] Some message if [ -n "$ds_prefix" ]; then # 2025-12-06 11:21:00 dyntls [DS:...]: [INFO] Text printf '%s dyntls %s: %s%s%s\n' \ "$ts" "$ds_prefix" "$level_tag" "$pad" "$msg" >>"$DYNTLS_LOG_FILE" else # 2025-12-06 11:21:00 dyntls [INFO] Text printf '%s dyntls %s%s%s\n' \ "$ts" "$level_tag" "$pad" "$msg" >>"$DYNTLS_LOG_FILE" fi fi # For levels >= 2 also print to stderr (including the domainset prefix) if [ "$level" -ge 2 ]; then printf '%s\n' "$msg" >&2 fi return 0 } #=> _log() # SDuesterhaupt: 2025-12-06 - Initialize per-domainset logging ID _init_domainset_id() { # 6-digit date stamp in the format YYMMDD ds_date=$(date +%y%m%d) # Build a hash source string from: # - the given domain set identifier (e.g. CN or full DYNTLS_DOMAINS) # - current UNIX timestamp # - current process ID # - a small chunk of random data from /dev/urandom (if available) hash_src="$1:$(date +%s):$$:$(od -An -N4 -tx4 /dev/urandom 2>/dev/null)" # Create an 8-character hexadecimal hash from the source string # Fallback to 'cksum' if 'sha256sum' is not available if command -v sha256sum >/dev/null 2>&1; then ds_hash=$(printf '%s\n' "$hash_src" | sha256sum 2>/dev/null | cut -c1-8) else ds_hash=$(printf '%s\n' "$hash_src" | cksum 2>/dev/null | awk '{print $1}' | cut -c1-8) fi # Compose the final domainset ID: YYMMDD-<8-hex-chars> DYNTLS_DOMAINSET_ID="${ds_date}-${ds_hash}" # Export the ID so that the logger and all sub-functions can use it export DYNTLS_DOMAINSET_ID } #=> _init_domainset_id() # SDuesterhaupt: 2025-09-14 - Validate hostname or extract parts # # Checks or extracts components of a hostname. # @param1: Mode ("check" or "get"). # @param2: Type ("tld", "sld", "fqdn", "hostname", "wildcard", "domain", "subdomain"). # Returns 0 if check passes or prints extracted value if get mode. # Exits fatally on invalid mode or type. # # Uses DYNTLS_MEMBER_HOSTNAME for input hostname. _Hostname() { _log "Enter the function '_Hostname()'..." 1 MyMethod=$1 MyDomainPart=$2 MyReturnFlag=0 _log "Method '$MyMethod' ..." 1 case "$MyMethod" in check) _log "Check type of hostname '$DYNTLS_MEMBER_HOSTNAME' ..." 1 case "$MyDomainPart" in tld) # Top Level Domain: # Must be alphabetic only, length 2–5 if printf '%s\n' "$DYNTLS_MEMBER_HOSTNAME" | grep -Eq '^[A-Za-z]{2,5}$'; then _log "Expression '$DYNTLS_MEMBER_HOSTNAME' is a valid TLD." 1 MyReturnFlag=0 else _log "Expression '$DYNTLS_MEMBER_HOSTNAME' is not a TLD." 3 MyReturnFlag=1 fi ;; sld) # Second Level Domain: # e.g. example.com if printf '%s\n' "$DYNTLS_MEMBER_HOSTNAME" | grep -Eq '^[A-Za-z0-9_-]+\.[A-Za-z]{2,5}$'; then _log "Expression '$DYNTLS_MEMBER_HOSTNAME' is a valid SLD." 1 MyReturnFlag=0 else _log "Expression '$DYNTLS_MEMBER_HOSTNAME' is not a valid SLD." 3 MyReturnFlag=1 fi ;; fqdn) # Fully Qualified Domain Name: # at least 3 parts, e.g. sub.example.com if printf '%s\n' "$DYNTLS_MEMBER_HOSTNAME" | grep -Eq '^([A-Za-z0-9_-]+\.){2,}[A-Za-z]{2,5}$'; then _log "Expression '$DYNTLS_MEMBER_HOSTNAME' is a valid FQDN." 1 MyReturnFlag=0 else _log "Expression '$DYNTLS_MEMBER_HOSTNAME' is not a valid FQDN." 3 MyReturnFlag=1 fi ;; hostname) # Generic hostnames with minimum pattern sld.tld if printf '%s\n' "$DYNTLS_MEMBER_HOSTNAME" | \ grep -Eq '^(([A-Za-z0-9][A-Za-z0-9_-]*[A-Za-z0-9]|[A-Za-z0-9])\.)+[A-Za-z]{2,5}$'; then _log "Expression '$DYNTLS_MEMBER_HOSTNAME' is a valid hostname." 1 MyReturnFlag=0 else _log "Expression '$DYNTLS_MEMBER_HOSTNAME' is not a valid hostname." 3 MyReturnFlag=1 fi ;; wildcard) # Wildcard domains must start with "*." followed by a valid hostname if printf '%s\n' "$DYNTLS_MEMBER_HOSTNAME" | \ grep -Eq '^\*\.[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*\.[A-Za-z]{2,5}$'; then _log "Expression '$DYNTLS_MEMBER_HOSTNAME' is a valid wildcard domain." 1 MyReturnFlag=0 else _log "Expression '$DYNTLS_MEMBER_HOSTNAME' is not a valid wildcard domain." 3 MyReturnFlag=1 fi ;; *) _die "Invalid action '$MyDomainPart' in function _Hostname()." 1 ;; esac ;; get) _log "Extract part of hostname '$DYNTLS_MEMBER_HOSTNAME' ..." 2 case "$MyDomainPart" in domain) # Extract base domain (SLD.TLD) from an FQDN # e.g. "sub.example.com" -> "example.com" MyDomain=$(printf '%s\n' "$DYNTLS_MEMBER_HOSTNAME" | sed -E 's/^.*\.([^.]+\.[A-Za-z]{2,5})$/\1/') printf '%s\n' "$MyDomain" ;; subdomain) # Extract leftmost subdomain from an FQDN # e.g. "sub.example.com" -> "sub" MySubdomain=$(printf '%s\n' "$DYNTLS_MEMBER_HOSTNAME" | sed -E 's/^([^.]+)\..*$/\1/') printf '%s\n' "$MySubdomain" ;; *) _die "Invalid action '$MyDomainPart' in function _Hostname()." 1 ;; esac ;; *) _die "Invalid method '$MyMethod' in function _Hostname()." 1 ;; esac _log "Leave the function '_Hostname()'..." 1 return $MyReturnFlag } #=> _Hostname() # SDuesterhaupt: 2025-09-14 - Build Subject Alternative Name list from DYNTLS_DOMAINS # # Parses DYNTLS_DOMAINS colon-separated list, validates each domain, # and populates DYNTLS_SAN_LIST with valid hostnames and wildcards. # # No input parameters. # Sets and exports DYNTLS_SAN_LIST. _SetupDomainSANList() { _log "Enter the function '_SetupDomainSANList()'..." 1 _log "DYNTLS_MEMBER_HOSTNAME: $DYNTLS_MEMBER_HOSTNAME" 1 _log "DYNTLS_DOMAINS: $DYNTLS_DOMAINS" 1 # Split DYNTLS_DOMAINS on ":" into an array #DomainAry=$(printf '%s\n' "$DYNTLS_DOMAINS" | sed 's/:/ /g') # Copy DYNTLS_DOMAINS to helper variable so the original is untouched DomainAry="$DYNTLS_DOMAINS" _log "DomainAry: $DomainAry" 1 # Initialize positional parameters safely # Temporarily set IFS to ':' and use set -- to create positional parameters (POSIX compatible) old_ifs="$IFS" IFS=":" set -- $DomainAry IFS="$old_ifs" # The CN (Common Name) is always the first and added to the SAN list first CN="$DYNTLS_MEMBER_HOSTNAME" export DYNTLS_SAN_LIST="$CN" _log "CN domain is '$CN'. Begin checking for additional SANs..." 1 # Iterate over all additional SAN domains (starting from the second parameter) shift for dom in "$@"; do _log "Checking additional SAN entry: $dom" 1 export DYNTLS_MEMBER_HOSTNAME="$dom" # Is the entry a valid hostname? if _Hostname "check" "hostname"; then DYNTLS_SAN_LIST="$DYNTLS_SAN_LIST $dom" continue fi # Is it a valid wildcard? if _Hostname "check" "wildcard"; then DYNTLS_SAN_LIST="$DYNTLS_SAN_LIST $dom" else _log "Domain '$dom' is not a valid hostname or wildcard. Skipped." 3 fi done # Always restore the original CN value after the loop export DYNTLS_MEMBER_HOSTNAME="$CN" _log "Resulting DYNTLS_SAN_LIST: $DYNTLS_SAN_LIST" 1 _log "Leave the function '_SetupDomainSANList()'..." 1 return 0 } #=> _SetupDomainSANList() # SDuesterhaupt: 2025-09-14 - Deploy issued certificates to configured services # # Copies or links certificate and key files to service PKI directories, # changes ownership and permissions, and reloads or restarts services as configured. # # Uses DYNTLS_DOMAINSERVICE_LIST and DYNTLS_MEMBER_HOSTNAME globals. # Returns 0 on success or 1 if errors occurred. _ProvideCertDomainService() { _log "Enter the function '_ProvideCertDomainService()'..." 1 _log "DYNTLS_MEMBER_HOSTNAME: $DYNTLS_MEMBER_HOSTNAME" 1 MyIsError=0 # Split list by '&' into individual domain-service mappings IFS=$'\n' DomainServiceAry=$(echo "$DYNTLS_DOMAINSERVICE_LIST" | sed 's/&/\n/g') for ServiceEntry in $DomainServiceAry; do # Split one mapping into fields IFS=":" set -- $ServiceEntry Domain="$1"; PkiDir="$2"; UserGroup="$3"; FilePerm="$4" Service="$5"; ServiceOwner="$6"; RestartFlag="$7"; RestartMode="$8"; DisplayName="$9" # Decide whether PkiDir is relative (default) or absolute (contains '/') case "$PkiDir" in */*) # Absolute path (or in general a path containing '/') EffectivePkiDir="$PkiDir" ;; *) # Relative service name > located below DYNTLS_PKI EffectivePkiDir="$DYNTLS_PKI/$PkiDir" ;; esac # Convert legacy user.group notation to user:group for chown to avoid deprecation warnings UG_USER=${UserGroup%%.*} UG_GROUP=${UserGroup#*.} UserGroup=$UG_USER:$UG_GROUP _log "Processing mapping: domain=$Domain, pkiDir=$PkiDir, userGroup=$UserGroup, perm=$FilePerm, service=$Service, owner=$ServiceOwner, restart=$RestartFlag, mode=$RestartMode, name=$DisplayName" 1 # Match against current hostname if [ "$Domain" = "$DYNTLS_MEMBER_HOSTNAME" ]; then _log "$DisplayName-Domain recognized! Try to provide the certificate to $DisplayName..." 2 # Ensure required directories exist _log "Domain '$Domain' matches CN. Preparing service directory '$EffectivePkiDir'..." 1 mkdir -p "$EffectivePkiDir/certs" "$EffectivePkiDir/private" # Set ownership/permissions on directories _log "Adjust the owner of '$EffectivePkiDir/'..." 1 find "$EffectivePkiDir" -type d | xargs -r chown "$UserGroup" find "$EffectivePkiDir" -type d | xargs -r chmod 750 # Provide base server key if missing _log "Check whether the server base key '$EffectivePkiDir/private/$DYNTLS_PKI_SERVER_BASEKEY_FILE' exists..." 1 if [ ! -f "$EffectivePkiDir/private/$DYNTLS_PKI_SERVER_BASEKEY_FILE" ]; then _log "$DYNTLS_PKI_SERVER_BASEKEY doesn't exist. Copy it..." 1 cp "$DYNTLS_PKI_SERVER_BASEKEY" "$EffectivePkiDir/private/" fi # Copy issued certificates into service PKI area _log "Copy the domain certificate, LE_CA chain and full chain from $DYNTLS_PKI_HTTP_CERT_DIR to the PKI service dir '$EffectivePkiDir/certs/'..." 1 cp "$DYNTLS_DOMAIN_TARGET_CERT" \ "$DYNTLS_PKI_LECA_CHAIN" \ "$DYNTLS_PKI_LECA_R12_CHAIN" \ "$DYNTLS_PKI_LECA_R13_CHAIN" \ "$DYNTLS_PKI_HTTP_CERT_DIR/$DYNTLS_MEMBER_HOSTNAME.$DYNTLS_PKI_FULLCHAIN_SUFFIX" \ "$EffectivePkiDir/certs/" # Provide server key: either symlink to base or copy unique key KeyPath="$EffectivePkiDir/private/$DYNTLS_MEMBER_HOSTNAME.$DYNTLS_PKI_KEY_SUFFIX" if [ "$DYNTLS_PKI_KEY_LNS" -eq 1 ]; then _log "Create a symlink '$EffectivePkiDir/private/base.$DYNTLS_PKI_KEY_SUFFIX' to '$KeyPath'." 1 ln -sf "$EffectivePkiDir/private/base.$DYNTLS_PKI_KEY_SUFFIX" "$KeyPath" else _log "Copy unique key from '$DYNTLS_PKI_HTTP_KEY_DIR/$DYNTLS_MEMBER_HOSTNAME.$DYNTLS_PKI_KEY_SUFFIX' to '$EffectivePkiDir/private/$DYNTLS_MEMBER_HOSTNAME.$DYNTLS_PKI_KEY_SUFFIX'." 1 cp "$DYNTLS_PKI_HTTP_KEY_DIR/$DYNTLS_MEMBER_HOSTNAME.$DYNTLS_PKI_KEY_SUFFIX" "$EffectivePkiDir/private/" fi # Adjust ownership and permissions on .pem files _log "Adjust the ownership of '$EffectivePkiDir/certs/*.pem'..." 1 find "$EffectivePkiDir/certs" -type f -name '*.pem' | xargs -r chown "$UserGroup" _log "Adjust the ownership of '$EffectivePkiDir/private/*.pem'..." 1 find "$EffectivePkiDir/private" -type f -name '*.pem' | xargs -r chown "$UserGroup" _log "Adjust the permissions of '$EffectivePkiDir/certs/*.pem'..." 1 find "$EffectivePkiDir/certs" -type f -name '*.pem' | xargs -r chmod "$FilePerm" _log "Adjust the permissions of '$EffectivePkiDir/private/*.pem'..." 1 find "$EffectivePkiDir/private" -type f -name '*.pem' | xargs -r chmod 440 _log "Certificates and keys provided for service '$DisplayName'." 2 # Restart or reload the service if configured if [ "$RestartFlag" -eq 1 ] && [ -n "$Service" ]; then # fallback to restart if mode is missing or invalid [ "$RestartMode" = "restart" ] || [ "$RestartMode" = "reload" ] || RestartMode="restart" if [ -n "$ServiceOwner" ] && [ "$ServiceOwner" != "root" ]; then _log "Reloading service '$Service' as non-root user '$ServiceOwner'..." 1 if sudo -u "$ServiceOwner" XDG_RUNTIME_DIR="/run/user/$(id -u $ServiceOwner)" \ systemctl --user "$RestartMode" "$Service.service"; then _log "Service '$DisplayName' successfully $RestartMode-ed as '$ServiceOwner'." 2 else _log "Error while $RestartMode-ing service '$DisplayName'." 4 MyIsError=1 fi else _log "Reloading service '$Service' as root..." 1 if systemctl "$RestartMode" "$Service.service"; then _log "Service '$DisplayName' successfully $RestartMode-ed as root." 2 else _log "Error while $RestartMode-ing service '$DisplayName'." 4 MyIsError=1 fi fi else _log "No restart required for service '$DisplayName'." 2 fi fi done _log "Leave the function '_ProvideCertDomainService()'..." 1 return $MyIsError } #=> _ProvideCertDomainService() # SDuesterhaupt: 2025-09-14 - Create or renew a certificate with Let's Encrypt # # Creates or renews certificate for a CN hostname and subject alt names, # calls external LE signing scripts, processes results, backs up old certs, # installs new certificates, and triggers webserver reload and service updates. # # Uses DYNTLS_MEMBER_HOSTNAME, DYNTLS_SAN_LIST, DYNTLS_DOMAINSERVICE_LIST # # Outputs files under PKI directories and triggers reload flags. _create_cert() { _log "Enter the function '_create_cert()'..." 1 # Debug context for this certificate run _log "CN (DYNTLS_MEMBER_HOSTNAME): $DYNTLS_MEMBER_HOSTNAME" 1 _log "SAN list (DYNTLS_SAN_LIST): $DYNTLS_SAN_LIST" 1 _log "Domain service mapping (DYNTLS_DOMAINSERVICE_LIST): $DYNTLS_DOMAINSERVICE_LIST" 1 ######################################## # 1. Temporary output for the new cert ######################################## mkdir -p "$DYNTLS_TMP" out_file_tmp="$DYNTLS_TMP/$DYNTLS_MEMBER_HOSTNAME.$DYNTLS_PKI_CERT_SUFFIX" _log "Temporary certificate file: $out_file_tmp" 1 ######################################## # 2. Build LE command options ######################################## # Initialize with empty DYNTLS_DNS_OPTIONS="" # Add all signing options DYNTLS_DNS_OPTIONS="-a $DYNTLS_ENCRYPT_ACCOUNTKEY -k $DYNTLS_PKI_SERVER_BASEKEY" _log "Added account key and base server key options: -a $DYNTLS_ENCRYPT_ACCOUNTKEY -k $DYNTLS_PKI_SERVER_BASEKEY" 1 # Staging mode: prepend -s [ "$DYNTLS_PRODUCTIVE" -eq 0 ] && { _log "LetsEncrypt staging mode enabled (-s)." 1 DYNTLS_DNS_OPTIONS="-s $DYNTLS_DNS_OPTIONS" } # http-01 vs dns-01 challenge if echo "$DYNTLS_SAN_LIST" | grep -E -q '(^\*\.)|( \*\.)'; then DYNTLS_DNS_ZONE=$(_Hostname get domain) _log "Wildcard detected in SAN list; using dns-01 challenge for zone '$DYNTLS_DNS_ZONE'." 1 DYNTLS_DNS_OPTIONS="$DYNTLS_DNS_OPTIONS -l dns-01 -d $DYNTLS_DNS_SERVER -t $DYNTLS_DNS_TSIG -z $DYNTLS_DNS_ZONE" _log "DNS challenge options: -l dns-01 -d $DYNTLS_DNS_SERVER -t $DYNTLS_DNS_TSIG -z $DYNTLS_DNS_ZONE" 1 else _log "No wildcard in SAN list; using http-01 challenge with token dir '$DYNTLS_ENCRYPT_TOKEN_DIR'." 1 DYNTLS_DNS_OPTIONS="$DYNTLS_DNS_OPTIONS -w $DYNTLS_ENCRYPT_TOKEN_DIR" fi DYNTLS_DNS_OPTIONS="$DYNTLS_DNS_OPTIONS -c $out_file_tmp" _log "Final ACME options string: $DYNTLS_DNS_OPTIONS" 1 ######################################## # 3. Run pre-commands if defined ######################################## if [ -n "$DYNTLS_CMD_PRE_LIST" ]; then for entry in $(echo "$DYNTLS_CMD_PRE_LIST" | sed 's/&/\n/g'); do cmd=$(echo "$entry" | cut -d: -f1 | sed 's/%20/ /g') remark=$(echo "$entry" | cut -d: -f2- | sed 's/%20/ /g') [ -n "$cmd" ] && { eval "$cmd" && _log "$remark" 1; } done fi ######################################## # 4. Call LE script ######################################## _log "Executing ACME client: $DYNTLS/$DYNTLS_LE_PROGRAM sign $DYNTLS_DNS_OPTIONS $DYNTLS_SAN_LIST" 1 MyLEError=$(eval "$DYNTLS/$DYNTLS_LE_PROGRAM sign $DYNTLS_DNS_OPTIONS $DYNTLS_SAN_LIST 2>&1") RetVal=$? _log "ACME client returned code: $RetVal" 1 [ -n "$MyLEError" ] && _log "ACME client output: $MyLEError" 1 ######################################## # 5. Run post-commands if defined ######################################## if [ -n "$DYNTLS_CMD_POST_LIST" ]; then for entry in $(echo "$DYNTLS_CMD_POST_LIST" | sed 's/&/\n/g'); do cmd=$(echo "$entry" | cut -d: -f1 | sed 's/%20/ /g') remark=$(echo "$entry" | cut -d: -f2- | sed 's/%20/ /g') [ -n "$cmd" ] && { eval "$cmd" && _log "$remark" 1; } done fi ######################################## # 6. Validate and install new certificate ######################################## if [ "$DYNTLS_PRODUCTIVE" -eq 1 ] && [ $RetVal -ne 0 ]; then _log "Error issuing certificate: $MyLEError" 4 rm -f "$out_file_tmp" elif [ -f "$out_file_tmp" ]; then _log "Temporary certificate file exists, starting validation." 1 if openssl x509 -checkend $(($DYNTLS_PKI_CERT_EXPIRE*86400)) -noout -in "$out_file_tmp"; then _log "The verification of the new certificate was successful. The certificate seems to be valid and it is moved to its destination folder." "2" _log "New certificate meets minimum validity window (${DYNTLS_PKI_CERT_EXPIRE} days)." 1 if [ "$DYNTLS_PRODUCTIVE" -eq 1 ]; then # Backup old cert BackupDir="$DYNTLS_PKI_HTTP_CERT_BACKUP_DIR/$DYNTLS_MEMBER_HOSTNAME" _log "Using backup directory: $BackupDir" 1 mkdir -p "$BackupDir" && chmod 750 "$BackupDir" && chown root:root "$BackupDir" [ -f "$DYNTLS_DOMAIN_TARGET_CERT" ] && { _log "Backup the expired certificate '$DYNTLS_DOMAIN_TARGET_CERT' to '$BackupDir/'." "2" cp "$DYNTLS_DOMAIN_TARGET_CERT" "$BackupDir/$DYNTLS_MEMBER_HOSTNAME.$DYNTLS_PKI_CERT_SUFFIX-$(date +%y%m%d)" } # Fix backup directory permissions and ownership recursively find "$BackupDir" -type d -exec chown root:root {} \; find "$BackupDir" -type d -exec chmod 750 {} \; find "$BackupDir" -type f -exec chown root:root {} \; find "$BackupDir" -type f -exec chmod 640 {} \; # Clean old backups if enabled if [ -n "$DYNTLS_BACKUP_EXPIRATION" ] && [ "$DYNTLS_BACKUP_EXPIRATION" -gt 0 ]; then _log "Removing backup files older than $DYNTLS_BACKUP_EXPIRATION days in $BackupDir" 2 find "$BackupDir" -type f -name "*.$DYNTLS_PKI_CERT_SUFFIX-*" -mtime +$DYNTLS_BACKUP_EXPIRATION -exec rm -f {} \; if [ -d "$BackupDir" ] && [ -z "$(find "$BackupDir" -type f -name "*.$DYNTLS_PKI_CERT_SUFFIX-*")" ]; then _log "Backup directory $BackupDir is empty; removing it." 2 rmdir "$BackupDir" fi else _log "Backup expiration disabled (DYNTLS_BACKUP_EXPIRATION=$DYNTLS_BACKUP_EXPIRATION). Skipping cleanup." 2 fi # Install new cert _log "Moving issued certificate to target: $DYNTLS_DOMAIN_TARGET_CERT" 2 mv "$out_file_tmp" "$DYNTLS_DOMAIN_TARGET_CERT" # Create fullchain file choosing correct chain (R12 vs R13) issuer_CN=$(openssl x509 -noout -issuer -in "$DYNTLS_DOMAIN_TARGET_CERT" | sed -n 's/^issuer=.*CN=//p') _log "Detected issuer CN for chain selection: $issuer_CN" 1 chainFile="$DYNTLS_PKI_LECA_R13_CHAIN" [ "$issuer_CN" = "R12" ] && chainFile="$DYNTLS_PKI_LECA_R12_CHAIN" _log "Using chain file: $chainFile" 1 fullchain_path="$DYNTLS_PKI_HTTP_CERT_DIR/$DYNTLS_MEMBER_HOSTNAME.$DYNTLS_PKI_FULLCHAIN_SUFFIX" _log "Creating fullchain file: $fullchain_path" 1 cat "$DYNTLS_DOMAIN_TARGET_CERT" "$chainFile" > "$fullchain_path" chmod 640 "$DYNTLS_PKI_HTTP_CERT_DIR"/*.pem* # Now copy or link the server key AFTER cert is issued KeyFile="$DYNTLS_PKI_HTTP_KEY_DIR/$DYNTLS_MEMBER_HOSTNAME.$DYNTLS_PKI_KEY_SUFFIX" _log "Planned server key path: $KeyFile" 1 if [ "$DYNTLS_PKI_KEY_LNS" -eq 1 ]; then _log "Linking base server key to: $KeyFile" 1 ln -sf "$DYNTLS_PKI_SERVER_BASEKEY" "$KeyFile" else _log "Copying dedicated server key to: $KeyFile" 1 cp -a "$DYNTLS_PKI_SERVER_BASEKEY" "$KeyFile" fi export DYNTLS_RELOAD_WEBSERVER="true" _log "Certificate installation complete, webserver reload requested." 1 # Provide to services if requested [ -n "$DYNTLS_DOMAINSERVICE_LIST" ] && _ProvideCertDomainService else _log "Staging mode: certificate issued but not installed." 2 rm -f "$out_file_tmp" export DYNTLS_RELOAD_WEBSERVER="false" fi else _log "Issued certificate does not satisfy validity window; removing temporary file." 4 rm -f "$out_file_tmp" fi fi unset DYNTLS_DNS_OPTIONS _log "Leave the function '_create_cert()'..." 1 return 0 } #=> _create_cert() # SDuesterhaupt: 2025-09-14 - Reload webserver configuration to activate new certs # # Runs apachectl configtest to validate syntax, # reloads httpd service if config OK, else logs error and aborts. # # Returns 0 on success or nonzero on failure. _reload_webserver() { _log "Enter the function '_reload_webserver()'..." 1 _log "\n----------------------------------------------\n" 6 ######################################## # 1. Check Apache config syntax ######################################## MySyntaxCheckResult=$(apachectl configtest 2>&1) if [ "$MySyntaxCheckResult" = "Syntax OK" ]; then _log "Apache config syntax OK." 2 ######################################## # 2. Reload httpd service ######################################## if systemctl reload httpd; then _log "Webserver reloaded successfully with new config." 2 else _log "Error: Failed to reload httpd service!" 4 MyIsError=true fi else ######################################## # Invalid config ######################################## _log "Apache configtest failed: $MySyntaxCheckResult" 4 _log "There is a problem with configured vHosts. Reload aborted." 4 MyIsError=true fi _log "Leave the function '_reload_webserver()'..." 1 return 0 } #=> _reload_webserver() # SDuesterhaupt: 2025-09-14 - Add a new certificate for a domain or SAN list # # Normalizes DYNTLS_DOMAINS input, # calls _SetupDomainSANList and _create_cert, # reloads webserver if certificates were updated. # # Exits the script upon completion. _add_cert() { _log "Enter the function '_add_cert()'..." 1 _log "Domains: $DYNTLS_DOMAINS" 1 # Initialize a fresh domainset ID for this explicit add-cert run # Uses the full DYNTLS_DOMAINS string (CN and all SANs) as hash source _init_domainset_id "$DYNTLS_DOMAINS" # Setup SAN list _SetupDomainSANList # Create the certificate _create_cert # Reload webserver if certificates were updated if $DYNTLS_RELOAD_WEBSERVER; then _reload_webserver else _log "Webserver reload not required (DYNTLS_RELOAD_WEBSERVER=false)" 2 fi _log "Leave the function '_add_cert()'..." 1 exit 0 } #=> _add_cert() # SDuesterhaupt: 2025-09-14 - Check whether a certificate exists for a given hostname # # Validates the hostname, # lists matching cert files in PKI directory, # logs results, # then exits. _check_cert() { _log "Enter the function '_check_cert()'..." 1 # Verify FQHN _Hostname "check" "hostname" if [ "$?" -ne 0 ]; then _log "The provided hostname '$DYNTLS_MEMBER_HOSTNAME' is not a valid FQHN. Aborting check." 3 _log "Leave the function '_check_cert()'..." 1 exit 1 fi # Check certificates _log "Checking for certificate file(s) in $DYNTLS_PKI_HTTP_CERT_DIR..." 1 for MyPath in "$DYNTLS_PKI_HTTP_CERT_DIR"/*."$DYNTLS_PKI_CERT_SUFFIX"; do [ -f "$MyPath" ] || continue MyCert="${MyPath##*/}" _log "Found certificate file: $MyCert" 1 if openssl x509 -noout -subject -in "$MyPath" 2>/dev/null | grep -q "$DYNTLS_MEMBER_HOSTNAME"; then _log "Certificate '$MyCert' contains CN/SAN for hostname '$DYNTLS_MEMBER_HOSTNAME'." 2 else _log "Certificate '$MyCert' does not match hostname '$DYNTLS_MEMBER_HOSTNAME'." 3 fi done # Add a blank line after finishing one domain's check printf '\n' >&2 _log "Leave the function '_check_cert()'..." 1 exit 0 } #=> _check_cert() # SDuesterhaupt: 2025-09-14 - Remove a certificate entry for a hostname # # Validates hostname, # checks HostnameDB for existence, # removes it if found, # and logs outcome. _remove_cert() { _log "Enter the function '_remove_cert()'..." 1 # Verify FQHN _Hostname "check" "hostname" if [ "$?" -ne 0 ]; then _log "The provided hostname '$DYNTLS_MEMBER_HOSTNAME' is not a valid FQHN. Cannot proceed." 3 _log "Leave the function '_remove_cert()'..." 1 exit 1 fi # Check HostnameDB _HostnameDB "check" if [ "$?" -eq 0 ]; then # Remove hostname _log "Hostname '$DYNTLS_MEMBER_HOSTNAME' found. Removing from HostnameDB..." 1 _HostnameDB "remove" _log "Hostname '$DYNTLS_MEMBER_HOSTNAME' successfully removed." 2 else _log "Cannot remove hostname '$DYNTLS_MEMBER_HOSTNAME'. It does not exist in HostnameDB." 2 fi _log "Leave the function '_remove_cert()'..." 1 exit 0 } #=> _remove_cert() # SDuesterhaupt: 2025-09-14 - Check and renew certificates if missing/expiring soon # # Loops over DYNTLS_DOMAIN_LIST entries, # validates CN format, # builds SAN list, # checks cert validity, # renews if necessary, # reloads webserver if updated, # optionally sends email notifications. # # Exits upon completion. _update_cert() { _log "Enter the function '_update_cert()'..." 1 ########################### # 1. Calculate expiration window ########################### MyExpSeconds=$((DYNTLS_PKI_CERT_EXPIRE*86400)) MyNowDate=$(date +%s) let MyTestTime=$MyNowDate+$MyExpSeconds _log "Expiration threshold: ${DYNTLS_PKI_CERT_EXPIRE} days (~until $(date --date="@$MyTestTime" '+%Y-%m-%d'))" 2 _log "Domain list: $DYNTLS_DOMAIN_LIST" 1 # Add a blank line after finishing one domain's check printf '\n' >&2 ########################### # 2. Loop over domain sets ########################### DomainList=$(echo "$DYNTLS_DOMAIN_LIST" | sed 's/&/\n/g') for DomainEntry in $DomainList; do # Initialize a fresh domainset ID for this specific CN+SAN set # This ID will be used as log prefix for all nested calls (_create_cert, _SetupDomainSANList, etc.) _init_domainset_id "$DomainEntry" _log "Checking domain entry: $DomainEntry" 1 CN=$(echo "$DomainEntry" | cut -d: -f1) _log "Check the CN domain '$CN'..." 2 export DYNTLS_MEMBER_HOSTNAME="$CN" export DYNTLS_DOMAINS="$DomainEntry" ########################### # 2a. Validate DYNTLS_MEMBER_HOSTNAME ########################### if ! _Hostname "check" "hostname" ; then _log "Skipping invalid CN '$DYNTLS_MEMBER_HOSTNAME' (not FQHN)" 3 continue fi ########################### # 2b. Setup SAN list ########################### _SetupDomainSANList ########################### # 2c. Locate existing certificate ########################### #export DYNTLS_DOMAIN_TARGET_CERT="$DYNTLS_PKI_HTTP_CERT_DIR/$CN.$DYNTLS_PKI_CERT_SUFFIX" export DYNTLS_DOMAIN_TARGET_CERT="$DYNTLS_PKI_HTTP_CERT_DIR/$DYNTLS_MEMBER_HOSTNAME.$DYNTLS_PKI_CERT_SUFFIX" if [ ! -f "$DYNTLS_DOMAIN_TARGET_CERT" ]; then _log "No existing certificate for '$DYNTLS_MEMBER_HOSTNAME'. Will create new one." 3 _create_cert continue fi _log "Found existing cert file: $DYNTLS_DOMAIN_TARGET_CERT" 1 ########################### # 2d. Check certificate validity ########################### EndDateStr=$(openssl x509 -noout -dates -in "$DYNTLS_DOMAIN_TARGET_CERT" | grep notAfter | sed -e "s/^.*notAfter=//") if [ -z "$EndDateStr" ]; then _log "Cert $DYNTLS_DOMAIN_TARGET_CERT invalid or corrupted. Reissue required." 4 _create_cert continue fi Expiry=$(date --date="$EndDateStr" +%s) ExpiryIso=$(date --date="@$Expiry" '+%Y-%m-%d %H:%M:%S') DaysLeft=$(( (Expiry - MyNowDate) / 86400 )) _log "CN '$DYNTLS_MEMBER_HOSTNAME' cert expires on $ExpiryIso (~$DaysLeft days left)" 2 if openssl x509 -checkend $MyExpSeconds -noout -in "$DYNTLS_DOMAIN_TARGET_CERT"; then _log "CN '$DYNTLS_MEMBER_HOSTNAME' cert still valid beyond threshold." 2 #continue # Commented out deliberately to execute blank line after every domain else _log "CN '$DYNTLS_MEMBER_HOSTNAME' cert will expire within $DYNTLS_PKI_CERT_EXPIRE days > renewing." 2 _create_cert fi # Add a blank line after finishing one domain's check printf '\n' >&2 done ########################### # 3. Reload web server if needed ########################### if $DYNTLS_RELOAD_WEBSERVER; then _reload_webserver fi ########################### # 4. Mail notification if enabled ########################### if $DYNTLS_SEND_MAIL; then Subject="Certificate check on $HOSTNAME" $MyIsError && Subject="ERROR: $Subject" echo -e "$MyMailText" | mail -s "$Subject" -r "$MyMailFrom" "$MyMailAddresses" fi _log "Leave the function '_update_cert()'..." 1 exit 0 } #=> _update_cert() ######################################## # Invocation entry point: NL=' ' # Be secure with a restrictive umask [ -z "$DYNTLS_NO_UMASK" ] && umask 077 # Ignore some env vars DYNTLS_PASSIN= DYNTLS_PASSOUT= # Parse options #echo "$@" while :; do opt="${1%%=*}" val="${1#*=}" case "$opt" in --days) # Accept --days=VAL or --days VAL if [ "${1#*=}" != "$1" ]; then export DYNTLS_PKI_CERT_EXPIRE="$val" shift else if [ -n "$2" ] && [ "${2%%--*}" = "$2" ]; then export DYNTLS_PKI_CERT_EXPIRE="$2" shift 2 else _die "Missing value for --days" 1 fi fi ;; --hostnames|-H) # Accept --hostnames=VAL or --hostnames VAL if [ "${1#*=}" != "$1" ]; then export DYNTLS_DOMAINS="$val" shift else if [ -n "$2" ] && [ "${2%%--*}" = "$2" ]; then export DYNTLS_DOMAINS="$2" shift 2 else _die "Missing value for --hostnames/-H" 1 fi fi # Normalize domain string # Replace whitespaces with ':' to keep structured domain list # export DYNTLS_DOMAINS=$(echo "$DYNTLS_DOMAINS" | sed -e 's/ */:/g') export DYNTLS_DOMAINS=$(echo "$DYNTLS_DOMAINS" | tr -s ' ' ':') ;; --key|-K) # Accept --key=VAL or --key VAL if [ "${1#*=}" != "$1" ]; then export DYNTLS_BIND_ZONE_KEY="$val" shift else if [ -n "$2" ] && [ "${2%%--*}" = "$2" ]; then export DYNTLS_BIND_ZONE_KEY="$2" shift 2 else _die "Missing value for --key/-K" 1 fi fi ;; --key-force-renew) # Accept --key-force-renew=VAL or --key-force-renew VAL if [ "${1#*=}" != "$1" ]; then export DYNTLS_PKI_KEY_FORCE_RENEW="$val" shift else if [ -n "$2" ] && [ "${2%%--*}" = "$2" ]; then export DYNTLS_PKI_KEY_FORCE_RENEW="$2" shift 2 else echo "Error: --key-force-renew requires an argument (1 or 0)" exit 1 fi fi ;; --productive|-P) export DYNTLS_PRODUCTIVE="1" shift ;; --DNS|-D) # Accept --DNS=VAL or --DNS VAL if [ "${1#*=}" != "$1" ]; then export DYNTLS_DNS_SERVER="$val" shift else if [ -n "$2" ] && [ "${2%%--*}" = "$2" ]; then export DYNTLS_DNS_SERVER="$2" shift 2 else _die "Missing value for --DNS/-D" 1 fi fi ;; --tsig|-T) # Accept --tsig=VAL or --tsig VAL if [ "${1#*=}" != "$1" ]; then export DYNTLS_DNS_TSIG="$val" shift else if [ -n "$2" ] && [ "${2%%--*}" = "$2" ]; then export DYNTLS_DNS_TSIG="$2" shift 2 else _die "Missing value for --tsig/-T" 1 fi fi ;; --vars) # Accept --vars=VAL or --vars VAL if [ "${1#*=}" != "$1" ]; then export DYNTLS_VARS_FILE="$val" shift else if [ -n "$2" ] && [ "${2%%--*}" = "$2" ]; then export DYNTLS_VARS_FILE="$2" shift 2 else _die "Missing value for --vars" 1 fi fi ;; *) break ;; esac done # SDuesterhaupt: 2019-07-16 - Intelligent env-var detection and auto-loading _vars_setup # SDuesterhaupt: 2019-07-28 - Create the log directory _init_log # SDuesterhaupt: 2019-07-16 - Register cleanup on EXIT trap "cleanup" EXIT # SDuesterhaupt: 2019-07-16 - When SIGHUP, SIGINT, SIGQUIT, SIGABRT and SIGTERM, # explicitly exit to signal EXIT (non-bash shells) trap "exit 1" 1 trap "exit 2" 2 trap "exit 3" 3 trap "exit 6" 6 trap "exit 14" 15 # SDuesterhaupt: 2025-09-14 - Validate required parameters for commands # # Checks mandatory options for commands like add-cert, # fails with clear error if missing. # # @param1: Command string (e.g., "add-cert") _validate_command_params() { local cmd="$1" case "$cmd" in add-cert) # Requires --hostnames / DYNTLS_DOMAINS to be set (one or more domains/SAN) if [ -z "$DYNTLS_DOMAINS" ]; then _die "'--hostnames' (DYNTLS_DOMAINS) option is required for add-cert command." 1 fi ;; check-cert) # Requires --hostnames / DYNTLS_DOMAINS (domain to check) if [ -z "$DYNTLS_DOMAINS" ]; then _die "'--hostnames' (DYNTLS_DOMAINS) option is required for check-cert command." 1 fi ;; remove-cert) # Requires --hostnames / DYNTLS_DOMAINS (domain to remove) if [ -z "$DYNTLS_DOMAINS" ]; then _die "'--hostnames' (DYNTLS_DOMAINS) option is required for remove-cert command." 1 fi ;; update-cert) # No mandatory CLI options, but could add checks if your workflow requires them. # Example: Validate DYNTLS_DOMAIN_LIST is set: # if [ -z "$DYNTLS_DOMAIN_LIST" ]; then # _die "'DYNTLS_DOMAIN_LIST' is required for update-cert command." 1 # fi ;; init-pki) # No mandatory parameters; PKI configuration is handled in _verify_pki_init ;; *) # Unknown or unsupported command; handled elsewhere. ;; esac } # SDuesterhaupt: 2019-07-16 - determine how we were called, then hand off to the function responsible cmd="$1" [ -n "$1" ] && shift # scrape off command # Validate required parameters for the command _validate_command_params "$cmd" case "$cmd" in #init-pki|clean-all) init-pki) _init_pki "$@" ;; add-cert) # SDuesterhaupt: 2019-09-11 - Verify the PKI directories _verify_pki_init _add_cert "$@" ;; check-cert) # SDuesterhaupt: 2019-09-11 - Verify the PKI directories _verify_pki_init _check_cert "$@" ;; remove-cert) # SDuesterhaupt: 2019-09-11 - Verify the PKI directories _verify_pki_init _remove_cert "$@" ;; update-cert) # SDuesterhaupt: 2019-09-11 - Verify the PKI directories _verify_pki_init _update_cert "$@" ;; ""|help|-h|--help|--usage) _cmd_help "$1" exit 0 ;; *) _die "Unknown command '$cmd'. Run without commands for usage help." "1" ;; esac # vim: ft=sh nu ai sw=8 ts=8 noet