Files
dyntls/dyntls.sh

1628 lines
59 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <me@jabber.stephanduesterhaupt.de>
# Ivo Noack aka Insonic <me@jabber.ivonoack.de>
#
# Copyright (c) 2018-2026 CB-601 - the open tec Elevator <mail@opensource-technology.de>
# License: MIT
#
# Project Home: https://dev.town-square.de/cb601/dyntls
#
###############################################################################
# MIT License
#
# Copyright (c) 2025 CB-601 - the open tec Elevator <mail@opensource-technology.de>
#
# 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 <filename_base> [ 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 25
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