diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2d79b27 --- /dev/null +++ b/LICENSE @@ -0,0 +1,42 @@ +MIT License + +Copyright (c) 2016-2026 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. + +--- + +## Third-party code + +This repository bundles external ACME client scripts under 'contrib/acme/': + +- 'letsencrypt_master.sh' + Upstream ACME client script (ght-acme.sh) by Attila Bruncsak and others, + licensed under the GNU General Public License, version 2 (or any later + version). The full license text is included in the script header. + +- 'letsencrypt_master_local.sh' + Locally adapted variant of the same upstream client, with modifications for + dynTLS integration (ACME server selection, dns-01 handling, http-01 token + permissions). It remains under the GNU GPLv2-or-later, as required by the + original project. + +These third-party files are clearly marked with their original copyright +and license information in their respective headers and are **not** covered +by the MIT license above. diff --git a/README.md b/README.md index ccf6931..bdf871a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,218 @@ -# dyntls +# 🔐 dynTLS -Granular Let’s Encrypt certificate management for multiple services and hosts. Uses external ACME backends (dns-01/http-01) and maps issued certs cleanly to system services. \ No newline at end of file +**Granular Let's Encrypt certificate management for multi-service environments** +Provides flexible ACME backend integration, DNS/http challenges and per-service certificate deployment. + +This utility simplifies issuing and managing TLS certificates via Let’s Encrypt for multiple services and hosts (e.g. Postfix, Dovecot, web servers and others). It separates ACME handling from deployment logic, supports HTTP-01 and DNS-01 challenges and maps issued certificates cleanly to individual services. + +Key features: + +- 🔁 Automated issuance and renewal of Let’s Encrypt certificates +- 🌐 Support for HTTP-01 and DNS-01 challenges via external ACME client +- 🧩 Flexible ACME backend selection (e.g. customized `letsencrypt_master_local.sh`) +- 🎯 Per-host and per-service certificate mapping with backup handling +- 📜 Central configuration via `vars` file +- 📂 Plain repository layout with optional contrib scripts under `./contrib/acme` + +--- + +## Outline + +1. [Features](#features) +2. [Installation](#installation) +3. [Configuration](#configuration) +4. [Directory Layout](#directory-layout) +5. [CRON Job Example](#cron-job-example) +6. [ACME Backend](#acme-backend) +7. [License](#license) +8. [Authors](#authors) +9. [Project Home](#project-home) + +--- + +## Features + +- Manages Let’s Encrypt certificates for multiple domains and services +- Uses a configurable ACME backend script (e.g. `letsencrypt_master_local.sh`) +- Supports HTTP-01 and DNS-01 challenges (e.g. via Bind/nsupdate, TSIG, `rndc`) +- Validates hostnames, SAN lists and certificate expiration before deployment +- Creates backups of replaced certificates and can prune old backups +- Logs operations with log levels and domain-set IDs for better traceability + +## Installation + +The following steps describe a simple, source-based installation. + +1. Clone the repository + + ```bash + git clone https://dev.town-square.de/cb601/dyntls.git /opt/dyntls + + cd /opt/dyntls + ``` +2. Install required packages + + Make sure you have at least: + - bash + - openssl + - nsupdate / bind-utils (for DNS-01 with Bind, if used) + - curl or wget (depending on your ACME script) + + On RPM-based systems, for example: + + ```bash + sudo dnf install openssl bind-utils curl -y + ``` +3. Prepare configuration + + Copy the example vars file and adapt it to your environment: + + ```bash + sudo cp vars.example vars + sudo chmod 640 vars + ``` +4. Test a dry run + + Before using dynTLS in production, run a dry or staging-mode test (example): + + ```bash + ./dyntls.sh help + ``` + +## Configuration + +dynTLS is configured via a `vars` file (e.g. `vars` or `vars.example`) which defines: + +- Base directories for PKI and HTTP token storage +- ACME backend script path (`DYNTLS_LE_PROGRAM`) +- Certificate renewal thresholds and backup behavior +- Domain lists and service mappings + +Key options include, for example: + +- `DYNTLS_LE_PROGRAM` – path to the external ACME client script +- `DYNTLS_PKI` – root PKI directory (certificates, keys, backups) +- `DYNTLS_PKI_CERT_EXPIRE` – minimum remaining validity (days) before renewal +- `DYNTLS_DOMAIN_LIST` – list of domains (CN + SANs) for a certificate +- `DYNTLS_DOMAINSERVICE_LIST` – mapping from certificate CN to service target(s) + +You can keep `vars.example` under version control and use a separate, local `vars` file (ignored by Git) for host-specific secrets and paths. + +## Directory Layout + +Minimal plain layout in the repository: + +```text +./ +├── dyntls # main dynTLS script +├── vars.example # example configuration +├── LICENSE.md # project license +├── README.md # this file +└── contrib/ + └── acme/ + ├── letsencrypt_master.sh + └── letsencrypt_master_local.sh +``` + +Suggested runtime layout on a host (example): + +| Path | Purpose | +|-----------------------------|---------------------------------| +| /opt/dyntls/dyntls.sh | Main script | +| /opt/dyntls/vars.example | Example config (reference) | +| /opt/dyntls/vars | User config (never overwritten) | +| /etc/pki/httpd/certs | Issued certificates | +| /etc/pki/httpd/private | Private keys | +| /etc/pki/httpd/certs/backup | Certificate backups | +| /var/log/dyntls/dyntls.log | dynTLS log file | +| /etc/cron.daily/dyntls | Cron job script (optional) | + +Adjust these paths in your `vars` file according to your distribution and service layout. + +## CRON Job Example + +To run the key generator daily, you have two options: + +1. Crontab + + You can add a daily cron job directly to the root user's crontab: + + Open the root crontab for editing: + + ```bash + sudo crontab -e + ``` + + Add the following line to run the script daily at 3:30 AM: + + ```bash + # Staging mode + 30 3 * * * /opt/dyntls/dyntls.sh update-cert + + # Productive mode + #30 3 * * * /opt/dyntls/dyntls.sh -P update-cert + ``` + + *(Adjust the schedule as needed. This example runs the script daily.)* +2. System Cron Daily Directory + + Create a script as `/etc/cron.daily/dyntls`: + + ```bash + #!/bin/sh + + # Staging mode + /opt/dyntls/dyntls.sh update-cert + + # Productive mode + #/opt/dyntls/dyntls.sh -P update-cert + + exit 0 + ``` + + Ensure the script is executable: + + ```bash + sudo chmod 750 /etc/cron.daily/dyntls + ``` + +## ACME Backend + +dynTLS does not implement the ACME protocol itself. Instead, it delegates all ACME communication to an external client script configured via `DYNTLS_LE_PROGRAM`. + +In this repository, ACME-related scripts are placed under: + +- `contrib/acme/letsencrypt_master.sh` + Upstream ACME client script (reference), unchanged or only minimally modified. +- `contrib/acme/letsencrypt_master_local.sh` + Local variant with customized `dns-01` challenge handling, for example using Bind/nsupdate, TSIG and `rndc` to manage internal DNS zones. + +Example configuration in `vars`: + +```bash +# Use local ACME backend +setvar DYNTLS_LE_PROGRAM "contrib/acme/letsencrypt_master_local.sh" + +# Pass DNS parameters to the ACME script (exported to its environment) +set_var DYNTLS_DNS_SERVER "root-dns.example365.tld" +set_var DYNTLS_DNS_TSIG "/opt/dyntls/private/tsig.key" +``` + +You can replace this with other ACME clients (e.g. acme.sh, lego) by pointing DYNTLS__LE_PROGRAM to your preferred script and ensuring its CLI options match your setup. + +## License + +[MIT](https://dev.town-square.de/cb601/dyntls/src/branch/main/LICENSE) + +See `LICENSE.md` for details and third-party license information. + +## Authors + +CB-601 - the open tec Elevator + +- [Stephan Düsterhaupt](xmpp:me@jabber.stephanduesterhaupt.de) +- [Ivo Noack](xmpp:me@jabber.ivonoack.de) aka Insonic + +## Project Home + +Project Home: \ No newline at end of file diff --git a/contrib/acme/letsencrypt_master.sh b/contrib/acme/letsencrypt_master.sh new file mode 100644 index 0000000..ab56eb3 --- /dev/null +++ b/contrib/acme/letsencrypt_master.sh @@ -0,0 +1,1489 @@ +#!/bin/sh + +# letsencrypt.sh - a simple shell implementation for the acme protocol +# Copyright (C) 2015 Gerhard Heift +# Copyright (C) 2016-2025 Attila Bruncsak +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +CADIR="https://api.test4.buypass.no/acme/directory" +CADIR="https://acme-staging-v02.api.letsencrypt.org/directory" + +# Prefix the following line with "# letsencrypt-production-server #", to use +# the staging server of letsencrypt. The staging server has lower rate limits, +# but does not issue valid certificates. To automatically remove the comment +# again on commiting the file, add the filter to your git config by running +# git config filter.production-server.clean misc/filter-production-server + +CADIR="https://api.buypass.com/acme/directory" +CADIR="https://acme-v02.api.letsencrypt.org/directory" + +# global variables: + +# base64url encoded JSON nonce, generated from Replay-Nonce header +# see gen_protected() +PROTECTED= + +# base64url encoded JSON request object +PAYLOAD= + +# base64url encoded signature of PROTECTED and PAYLOAD +# see also gen_signature() +SIGNATURE= + +# the account key used to send the requests and to verify the domain challenges +ACCOUNT_KEY= + +# the JSON Web Key is the representation of the key as JSON object +ACCOUNT_JWK= + +# the JSON object to specify the signature format +ACCOUNT_ID= + +# the thumbprint is the checksum of the JWK and is used for the challenges +ACCOUNT_THUMB= + +# the private key, which should be signed by the CA +SERVER_KEY= + +# the certificate signing request, which sould be used +SERVER_CSR= + +# the location, where the certificate should be stored +SERVER_CERT= + +# the location, where the certificate with the signing certificate(s) should be stored +SERVER_FULL_CHAIN= + +# the location, where the signing certificate(s) should be stored +SERVER_SIGNING_CHAIN= + +# selection of the signing chain +SIGNING_CHAIN_SELECTION=0 + +# the e-mail address to be used with the account key, only needed if account +# key is not yet registred +ACCOUNT_EMAIL= + +# a list of domains, which should be assigned to the certificate +DOMAINS= + +# a list of domains, challenge uri, token and authorization uri +DOMAIN_DATA= + +# the directory, where to push the response +# $DOMAIN or ${DOMAIN} will be replaced with the actual domain +WEBDIR= + +# the script to be called to push the response to a remote server +PUSH_TOKEN= + +# the script to be called to push the response to a remote server needs the commit feature +PUSH_TOKEN_COMMIT= + +# set the option of the preferred IP family for connecting to the ACME server +IPV_OPTION= + +# the challenge type, can be dns-01 or http-01 (default) +CHALLENGE_TYPE="http-01" + +# the date of the that version +VERSION_DATE="2025-12-01" + +# The meaningful User-Agent to help finding related log entries in the ACME server log +USER_AGENT="bruncsak/ght-acme.sh $VERSION_DATE" + +LOGLEVEL=1 + +# utility functions + +tolower() { + printf '%s' "$*" | tr A-Z a-z +} + +HexadecimalStringToOctalEscapeSequence() { +tr '[A-F]' '[a-f]' "$@" | tr -d '\r\n' | +sed -e 's/[^0-9a-f]//g; s/^\(\(..\)\{0,\}\).$/\1/; +s/\([0-9a-f]\)\([0-9a-f]\)/\1_\2/g; s/$/\\c/; +s/_0/o0/g; s/_1/o1/g; s/_2/o2/g; s/_3/o3/g; +s/_4/o4/g; s/_5/o5/g; s/_6/o6/g; s/_7/o7/g; +s/_8/i0/g; s/_9/i1/g; s/_a/i2/g; s/_b/i3/g; +s/_c/i4/g; s/_d/i5/g; s/_e/i6/g; s/_f/i7/g; +s/0o/\\000/g; s/0i/\\001/g; s/1o/\\002/g; s/1i/\\003/g; +s/2o/\\004/g; s/2i/\\005/g; s/3o/\\006/g; s/3i/\\007/g; +s/4o/\\010/g; s/4i/\\011/g; s/5o/\\012/g; s/5i/\\013/g; +s/6o/\\014/g; s/6i/\\015/g; s/7o/\\016/g; s/7i/\\017/g; +s/8o/\\020/g; s/8i/\\021/g; s/9o/\\022/g; s/9i/\\023/g; +s/ao/\\024/g; s/ai/\\025/g; s/bo/\\026/g; s/bi/\\027/g; +s/co/\\030/g; s/ci/\\031/g; s/do/\\032/g; s/di/\\033/g; +s/eo/\\034/g; s/ei/\\035/g; s/fo/\\036/g; s/fi/\\037/g; +' +} + +hex2bin() { + xxd -r -p + # echo $ECHOESCFLAG "`HexadecimalStringToOctalEscapeSequence`" +} + +base64url() { + openssl base64 | tr '+/' '-_' | tr -d '\r\n=' +} + +log() { + if [ "$LOGLEVEL" -gt 0 ]; then + echo "$@" >& 2 + fi +} + +dbgmsg() { + if [ "$LOGLEVEL" -gt 1 ]; then + echo "$@" >& 2 + fi +} + +errmsg() { + echo "$@" >& 2 +} + +err_exit() { + RETCODE="$?" + [ -n "$2" ] && RETCODE="$2" + [ -n "$1" ] && printf "%s\n" "$1" >& 2 + exit "$RETCODE" +} + +required_commands() { + REQUIRED_COMMANDS="basename cat cp rm sed grep egrep fgrep tr mktemp expr tail xxd openssl" + + if [ "$USE_WGET" = yes ] ;then + REQUIRED_COMMANDS="$REQUIRED_COMMANDS wget" + else + REQUIRED_COMMANDS="$REQUIRED_COMMANDS curl" + fi + for command in $REQUIRED_COMMANDS ;do + command -v $command > /dev/null || err_exit "The command '$command' is required to run $PROGNAME" + done +} + +validate_domain() { + DOMAIN_IN="$1" + if [ "$DOMAIN_IN" = _ ]; then + return 1 + fi + + DOMAIN_OUT="`printf "%s\n" "$DOMAIN_IN" | sed -e 's/^...$/!/; s/^.\{254,\}$/!/; s/^'"$DOMAIN_EXTRA_PAT"'\([a-zA-Z0-9]\([-a-zA-Z0-9]\{0,61\}[a-zA-Z0-9]\)\{0,1\}\.\)\{1,\}\([a-zA-Z]\([-a-zA-Z0-9]\{0,61\}[a-zA-Z]\)\)$/_/;'`" + + if [ "$DOMAIN_OUT" = _ ]; then + return 0 + else + return 1 + fi +} + +handle_wget_exit() { + WGET_EXIT="$1" + WGET_URI="$2" + + if [ "$WGET_EXIT" -ne 0 -a "$WGET_EXIT" -ne 8 -o -s "$WGET_OUT" ]; then + echo "error while making a web request to \"$WGET_URI\"" >& 2 + echo "wget exit status: $WGET_EXIT" >& 2 + case "$WGET_EXIT" in + # see man wget "EXIT STATUS" + 4) echo " Network failure" >& 2;; + 5) echo " SSL verification failure" >& 2;; + 8) echo " Server issued an error response" >& 2;; + esac + + cat "$WGET_OUT" >& 2 + cat "$RESP_HEABOD" >& 2 + + exit 1 + elif [ "$WGET_EXIT" -eq 8 -a ! -s "$RESP_HEABOD" ] ;then + echo "error while making a web request to \"$WGET_URI\"" >& 2 + echo "wget exit status: $WGET_EXIT" >& 2 + err_exit "Server issued an error response and no error document returned and no --content-on-error flag available. Upgrade your wget or use curl instead." 1 + fi + + tr -d '\r' < "$RESP_HEABOD" | sed -e '/^$/,$d' > "$RESP_HEADER" + tr -d '\r' < "$RESP_HEABOD" | sed -e '1,/^$/d' > "$RESP_BODY" +} + +curl_return_text() +{ + CURL_EXIT="$1" + case "$CURL_EXIT" in + # see man curl "EXIT CODES" + 3) TXT=", malformed URI" ;; + 6) TXT=", could not resolve host" ;; + 7) TXT=", failed to connect" ;; + 28) TXT=", operation timeout" ;; + 35) TXT=", SSL connect error" ;; + 52) TXT=", the server did not reply anything" ;; + 56) TXT=", failure in receiving network data" ;; + *) TXT="" ;; + esac + printf "curl return status: %d%s" "$1" "$TXT" +} + +curl_loop() +{ + CURL_ACTION="$1"; shift + loop_count=0 + pluriel="" + while : ;do + dbgmsg "About making a web request to \"$CURL_ACTION\"" + curl "$@" + CURL_RETURN_CODE="$?" + [ "$loop_count" -ge 20 ] && break + case "$CURL_RETURN_CODE" in + 6) ;; + 7) ;; + 28) ;; + 35) ;; + 52) ;; + 56) ;; + *) break ;; + esac + loop_count=$((loop_count + 1 )) + dbgmsg "While making a web request to \"$CURL_ACTION\" sleeping $loop_count second$pluriel before retry due to `curl_return_text $CURL_RETURN_CODE`" + sleep "$loop_count" + pluriel="s" + done + if [ "$CURL_RETURN_CODE" -ne 0 ] ;then + errmsg "While making a web request to \"$CURL_ACTION\" error exiting due to `curl_return_text $CURL_RETURN_CODE` (retry number: $loop_count)" + exit "$CURL_RETURN_CODE" + else + dbgmsg "While making a web request to \"$CURL_ACTION\" continuing due to `curl_return_text $CURL_RETURN_CODE`" + fi +} + +handle_openssl_exit() { + OPENSSL_EXIT=$1 + OPENSSL_ACTION=$2 + + if [ "$OPENSSL_EXIT" "!=" 0 ]; then + echo "error while $OPENSSL_ACTION" >& 2 + echo "openssl exit status: $OPENSSL_EXIT" >& 2 + cat "$OPENSSL_ERR" >& 2 + exit 1 + fi +} + +fetch_http_status() { + HTTP_STATUS="`sed -e '/^HTTP\// !d; s/^HTTP\/[0-9.]\{1,\} *\([^ ]*\).*$/\1/' "$RESP_HEADER" | tail -n 1`" +} + +check_http_status() { + [ "$HTTP_STATUS" = "$1" ] +} + +check_acme_error() { + fgrep -q "urn:ietf:params:acme:error:$1" "$RESP_BODY" +} + +unhandled_response() { + echo "unhandled response while $1" >& 2 + echo >& 2 + + cat "$RESP_HEADER" "$RESP_BODY" >& 2 + + echo >& 2 + + exit 1 +} + +show_error() { + if [ -n "$1" ]; then + echo "error while $1" >& 2 + fi + + ERR_TYPE="`tr -d '\r\n' < "$RESP_BODY" | sed -e 's/.*"type": *"\([^"]*\)".*/\1/'`" + ERR_DETAILS="`tr -d '\r\n' < "$RESP_BODY" | sed -e 's/.*"detail": *"\([^"]*\)".*/\1/'`" + + + echo " $ERR_DETAILS ($ERR_TYPE)" >& 2 +} + +header_field_value() { + grep -i -e "^$1:.*$2" "$RESP_HEADER" | sed -e 's/^[^:]*: *//' | tr -d '\r\n' +} + +fetch_next_link() { + header_field_value Link ';rel="next"' | sed -s 's/^.*<\(.*\)>.*$/\1/' +} + +fetch_alternate_link() { + header_field_value Link ';rel="alternate"' | sed -s 's/^.*<\(.*\)>.*$/\1/' +} + +fetch_location() { + header_field_value Location +} + +# retrieve the nonce from the response header of the actual request for the forthcomming POST request + +extract_nonce() { + new_nonce="`header_field_value Replay-Nonce`" + if [ -n "$new_nonce" ] ;then + # Log if we had unnecesseraily multiple nonces, but use always the latest nonce + [ -n "$NONCE" ] && log "droping unused nonce: $NONCE" + NONCE="$new_nonce" + dbgmsg " new nonce: $NONCE" + else + dbgmsg "no new nonce" + fi +} + +retry_after() { + header_field_value Retry-After +} + +sleep_retryafter() { + RETRY_AFTER="`retry_after`" + if printf '%s' "$RETRY_AFTER" | egrep -s -q -e '^[1-9][0-9]*$' ;then + if [ "$RETRY_AFTER" -gt 61 ] ;then + log "Too big Retry-After header field value: $RETRY_AFTER" + RETRY_AFTER=61 + fi + [ "$RETRY_AFTER" -eq 1 ] && pluriel="" || pluriel="s" + log "sleeping $RETRY_AFTER second$pluriel" + sleep $RETRY_AFTER + else + log "Could not retrieve expected Retry-After header field value: $RETRY_AFTER" + sleep 1 + fi +} + +server_overload() { + if check_http_status 503 && check_acme_error rateLimited ;then + log "busy server rate limit condition" + sleep_retryafter + return 0 + else + return 1 + fi +} + +server_request() { + dbgmsg "server_request: $1 $2" + if [ "$USE_WGET" != yes ] ;then + if [ -n "$2" ] ;then + curl_loop "$1" $CURLEXTRAFLAG -s $IPV_OPTION -A "$USER_AGENT" -D "$RESP_HEADER" -o "$RESP_BODY" -H "Content-type: application/jose+json" -d "$2" "$1" + else + curl_loop "$1" $CURLEXTRAFLAG -s $IPV_OPTION -A "$USER_AGENT" -D "$RESP_HEADER" -o "$RESP_BODY" "$1" + fi + else + if [ -n "$2" ] ;then + wget $WGETEXTRAFLAG -q $IPV_OPTION -U "$USER_AGENT" --retry-connrefused --save-headers $WGETCOEFLAG -O "$RESP_HEABOD" --header="Content-type: application/jose+json" --post-data="$2" "$1" > "$WGET_OUT" 2>& 1 + else + wget $WGETEXTRAFLAG -q $IPV_OPTION -U "$USER_AGENT" --retry-connrefused --save-headers $WGETCOEFLAG -O "$RESP_HEABOD" "$1" > "$WGET_OUT" 2>& 1 + fi + handle_wget_exit $? "$1" + fi + fetch_http_status +} + +request_acme_server() { + while : ;do + server_request "$1" "$2" + extract_nonce + server_overload || return + done +} + +# generate the PROTECTED variable, which contains a nonce retrieved from the +# server in the Replay-Nonce header + +gen_protected(){ + if [ -z "$NONCE" ]; then + dbgmsg "fetch new nonce" + send_get_req "$NEWNONCEURL" + [ -n "$NONCE" ] || err_exit "could not fetch new nonce" + fi + + printf '%s' '{"alg":"RS256",'"$ACCOUNT_ID"',"nonce":"'"$NONCE"'","url":"'"$1"'"}' +} + +# generate the signature for the request + +gen_signature() { + printf '%s' "$1" | + openssl dgst -sha256 -binary -sign "$ACCOUNT_KEY" 2> "$OPENSSL_ERR" + handle_openssl_exit "$?" "signing request" +} + +# helper functions to create the json web key object + +key_get_modulus(){ + openssl rsa -in "$1" -modulus -noout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + handle_openssl_exit $? "extracting account key modulus" + + sed -e 's/^Modulus=//' < "$OPENSSL_OUT" \ + | hex2bin \ + | base64url +} + +key_get_exponent(){ + openssl rsa -in "$1" -text -noout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + handle_openssl_exit $? "extracting account key exponent" + + sed -e '/^publicExponent: / !d; s/^publicExponent: [0-9]* \{1,\}(\(.*\)).*$/\1/;s/^0x\([0-9a-fA-F]\)\(\([0-9a-fA-F][0-9a-fA-F]\)*\)$/0x0\1\2/;s/^0x\(\([0-9a-fA-F][0-9a-fA-F]\)*\)$/\1/' \ + < "$OPENSSL_OUT" \ + | hex2bin \ + | base64url +} + +# make a request to the specified URI +# the payload is signed by the ACCOUNT_KEY +# the response header is stored in the file $RESP_HEADER, the body in the file $RESP_BODY + +send_req_no_kid(){ + URI="$1" + + PAYLOAD="`printf '%s' "$2" | base64url`" + while : ;do + PROTECTED="`gen_protected "$URI" | base64url`" + SIGNATURE="`gen_signature $PROTECTED.$PAYLOAD | base64url`" + + DATA='{"protected":"'"$PROTECTED"'","payload":"'"$PAYLOAD"'","signature":"'"$SIGNATURE"'"}' + + # Use only once a nonce + NONCE="" + + request_acme_server "$URI" "$DATA" + + if ! check_http_status 400; then + return + elif ! check_acme_error badNonce ;then + return + fi + if [ -z "$BAD_NONCE_MSG" ] ;then + BAD_NONCE_MSG=yes + echo "badNonce warning: other than extrem load on the ACME server," >& 2 + echo "this is mostly due to multiple client egress IP addresses," >& 2 + echo "including working IPv4 and IPv6 addresses on dual family systems." >& 2 + echo "In that case as a workaround please try to restrict the egress" >& 2 + echo "IP address with the -4 or -6 command line option on the script." >& 2 + echo "This message is just a warning, continuing safely." >& 2 + fi + # Bad nonce condition. Here we do not sleep to be nice, just loop immediately. + # The error cannot be on the client side, since it is guaranted that we used the latest available nonce. + done +} + +send_req(){ + URI="$1" + + [ -z "$KID" ] && register_account_key retrieve_kid + + send_req_no_kid "$1" "$2" +} + +send_get_req(){ + request_acme_server "$1" +} + +pwncheck(){ + server_request "https://v1.pwnedkeys.com/$1" + if check_http_status 404; then + log "pwnedkeys.com claims: $2 is not compromised" + return 0 + elif check_http_status 200; then + echo "pwnedkeys.com claims: $2 is compromised, fingerprint: $1" >& 2 + return 1 + fi + unhandled_response "pwncheck" +} + +pkey_hex_digest(){ + openssl dgst -sha256 -hex "$1" > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + handle_openssl_exit $? "public key DER hexdigest" + sed -e 's/^.*= *//' "$OPENSSL_OUT" +} + +pwnedkey_req_check(){ + [ "$PWNEDKEY_CHECK" = no ] && return + openssl req -in "$1" -noout -pubkey > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + handle_openssl_exit $? "extracting request public key" + cp "$OPENSSL_OUT" "$OPENSSL_IN" + if ! openssl pkey -in "$OPENSSL_IN" -pubin -outform der -pubout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" ;then + # On old openssl there is no EC key. There we default to RSA. + openssl rsa -in "$OPENSSL_IN" -pubin -outform der -pubout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + fi + handle_openssl_exit $? "request public key to DER" + cp "$OPENSSL_OUT" "$OPENSSL_IN" + pwncheck "`pkey_hex_digest "$OPENSSL_IN"`" "$2" +} + +pwnedkey_key_check(){ + [ "$PWNEDKEY_CHECK" = no ] && return + if ! openssl ec -in "$1" -outform der -pubout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" ;then + openssl rsa -in "$1" -outform der -pubout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + fi + handle_openssl_exit $? "public key to DER" + cp "$OPENSSL_OUT" "$OPENSSL_IN" + pwncheck "`pkey_hex_digest "$OPENSSL_IN"`" "$2" +} + +# account key handling + +load_account_key(){ + [ -n "$ACCOUNT_KEY" ] || err_exit "no account key specified" + [ -r "$ACCOUNT_KEY" ] || err_exit "could not read account key" + + openssl rsa -in "$ACCOUNT_KEY" -noout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + handle_openssl_exit $? "opening account key" + + ACCOUNT_JWK='{"e":"'"`key_get_exponent $ACCOUNT_KEY`"'","kty":"RSA","n":"'"`key_get_modulus $ACCOUNT_KEY`"'"}' + ACCOUNT_ID='"jwk":'"$ACCOUNT_JWK" + ACCOUNT_THUMB="`printf '%s' "$ACCOUNT_JWK" | openssl dgst -sha256 -binary | base64url`" + + if [ -z "$1" ] ;then + if [ "$ACCOUNT_KEY" = "$SERVER_KEY" ] ;then + # We should allow revoking with compromised certificate key too + pwnedkey_key_check "$ACCOUNT_KEY" "server key as account key" || log "revoking certificate with compromised key" + else + pwnedkey_key_check "$ACCOUNT_KEY" "account key" || exit + fi + fi +} + +get_one_url(){ + if ! egrep -s -q -e '"'"$1"'"' "$RESP_BODY" ;then + cat "$RESP_BODY" >& 2 + err_exit "Cannot retrieve URL for $1 ACME protocol function from the directory $CADIR" 1 + fi + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*"'"$1"'":"\([^"]*\)".*/\1/' +} + +get_urls(){ + if [ "$USE_WGET" = yes ] ;then + WGETCOEFLAG='--content-on-error' + wget --help | egrep -s -q -e "$WGETCOEFLAG" || WGETCOEFLAG='' + fi + + send_get_req "$CADIR" + if ! check_http_status 200 ;then + unhandled_response "fetching directory URLs" + fi + + NEWACCOUNTURL="`get_one_url newAccount`" + REVOKECERTURL="`get_one_url revokeCert`" + KEYCHANGEURL="`get_one_url keyChange`" + NEWNONCEURL="`get_one_url newNonce`" + NEWORDERURL="`get_one_url newOrder`" +} + +orders_url() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e '/"orders":"/ !d; s/.*"orders":"\([^"]*\)".*/\1/' +} + +orders_list() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/^.*"orders":\[\([^]]*\)\].*$/\1/' | tr -d '"' | tr ',' ' ' +} + +register_account_key(){ + + [ -n "$NEWACCOUNTURL" ] || get_urls + if [ -n "$ACCOUNT_EMAIL" ] ;then + NEW_REG='{"termsOfServiceAgreed":true,"contact":["mailto:'"$ACCOUNT_EMAIL"'"]}' + else + NEW_REG='{"onlyReturnExisting":true}' + fi + send_req_no_kid "$NEWACCOUNTURL" "$NEW_REG" + + if check_http_status 200; then + [ "$1" = "retrieve_kid" ] || err_exit "account already registered" + KID="`fetch_location`" + ACCOUNT_ID='"kid":"'"$KID"'"' + ORDERS_URL="`orders_url`" + return + elif check_http_status 201; then + KID="`fetch_location`" + ACCOUNT_ID='"kid":"'"$KID"'"' + ORDERS_URL="`orders_url`" + return + elif check_http_status 409; then + [ "$1" = "nodie" ] || err_exit "account already exists" + elif check_http_status 400 && check_acme_error accountDoesNotExist ;then + show_error "fetching account information" + exit 1 + else + unhandled_response "registering account" + fi +} + +clrpenda() { + ORDERS_LIST="" + while [ -n "$ORDERS_URL" ]; do + send_req "$ORDERS_URL" "" + if check_http_status 200; then + ORDERS_LIST="$ORDERS_LIST `orders_list`" + else + unhandled_response "retrieving orders list" + fi + ORDERS_URL="`fetch_next_link`" + done + + DOMAIN_AUTHZ_LIST="" + set -- $ORDERS_LIST + + for ORDER do + send_req "$ORDER" "" + if check_http_status 200; then + ORDER_STATUS="`order_status`" + if [ "$ORDER_STATUS" = pending ] ;then + DOMAIN_AUTHZ_LIST="$DOMAIN_AUTHZ_LIST `domain_authz_list`" + fi + else + unhandled_response "retrieving order" + fi + done + + # All domain should have that challenge type, even wildcard one + CHALLENGE_TYPE=dns-01 + + set -- $DOMAIN_AUTHZ_LIST + + for DOMAIN_AUTHZ do + send_req "$DOMAIN_AUTHZ" "" + if check_http_status 200; then + DOMAIN="`authz_domain`" + AUTHZ_STATUS="`authz_status`" + if [ "$AUTHZ_STATUS" = pending ] ;then + DOMAIN_URI="`authz_domain_uri`" + log "retrieve challenge for $DOMAIN" + request_domain_verification + fi + else + unhandled_response "retrieve challenge for URL: $DOMAIN_AUTHZ" + fi + done +} + +delete_account_key(){ + log "delete account" + + REG='{"resource":"reg","delete":"true"}' + send_req "$REGISTRATION_URI" "$REG" + + if check_http_status 200; then + return + else + unhandled_response "deleting account" + fi +} + +check_server_domain() { + if [ "$2" = true ] ;then + SERVER_DOMAIN="*.$1" + else + SERVER_DOMAIN="$1" + fi + SERVER_DOMAIN_LOWER="`tolower $SERVER_DOMAIN`" + + set -- $DOMAINS + + for REQ_DOMAIN do + if [ "$SERVER_DOMAIN_LOWER" = "`tolower $REQ_DOMAIN`" ] ;then + return + fi + done + err_exit "ACME server requested authorization for a rogue domain: $SERVER_DOMAIN" 1 +} + +authz_status() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*"status":"\([^"]*\)".*/\1/' +} + +authz_domain() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*"identifier":{"type":"dns","value":"\([^"]*\)"}.*/\1/' +} + +wildcard_domain() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e '/"wildcard":/ !d; s/^.*"wildcard":\([a-z]*\).*$/\1/' +} + +authz_domain_token() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*{\([^}]*"type":"'"$CHALLENGE_TYPE"'"[^}]*\)}.*/\1/; s/.*"token":"\([^"]*\)".*/\1/' +} + +authz_domain_uri() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*{\([^}]*"type":"'"$CHALLENGE_TYPE"'"[^}]*\)}.*/\1/; s/.*"url":"\([^"]*\)".*/\1/' +} + +request_challenge_domain(){ + + send_req "$DOMAIN_AUTHZ" "" + + if check_http_status 200; then + DOMAIN="`authz_domain`" + AUTHZ_STATUS="`authz_status`" + case "$AUTHZ_STATUS" in + valid) + log "authorization is valid for $DOMAIN" + ;; + pending) + check_server_domain "$DOMAIN" "`wildcard_domain`" + DOMAIN_TOKEN="`authz_domain_token`" + DOMAIN_URI="`authz_domain_uri`" + DOMAIN_DATA="$DOMAIN_DATA $DOMAIN $DOMAIN_URI $DOMAIN_TOKEN $DOMAIN_AUTHZ" + log "retrieve challenge for $DOMAIN" + ;; + *) + echo authorization status: "$AUTHZ_STATUS" >& 2 + unhandled_response "checking authorization status for domain $DOMAIN" + ;; + esac + elif check_http_status 400; then + # account not registred? + show_error "retrieve challenge for URL: $DOMAIN_AUTHZ" + exit 1 + elif check_http_status 403; then + # account not registred? + show_error "retrieve challenge for URL: $DOMAIN_AUTHZ" + exit 1 + else + unhandled_response "retrieve challenge for URL: $DOMAIN_AUTHZ" + fi +} + +domain_authz_list() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/^.*"authorizations":\[\([^]]*\)\].*$/\1/' | tr -d '"' | tr ',' ' ' +} + +finalize() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/^.*"finalize":"\([^"]*\).*$/\1/' +} + +request_challenge(){ + log "creating new order" + + set -- $DOMAINS + for DOMAIN do + [ -n "$DOMAIN_ORDERS" ] && DOMAIN_ORDERS="$DOMAIN_ORDERS," + DOMAIN_ORDERS="$DOMAIN_ORDERS"'{"type":"dns","value":"'"$DOMAIN"'"}' + done + + [ -n "$NEWORDERURL" ] || get_urls + NEW_ORDER='{"identifiers":['"$DOMAIN_ORDERS"']}' + send_req "$NEWORDERURL" "$NEW_ORDER" + if check_http_status 201; then + DOMAIN_AUTHZ_LIST="`domain_authz_list`" + FINALIZE="`finalize`" + CURRENT_ORDER="`fetch_location`" + else + unhandled_response "requesting new order for $DOMAINS" + fi + set -- $DOMAIN_AUTHZ_LIST + for DOMAIN_AUTHZ do + request_challenge_domain + done + +} + +domain_commit() { + if [ -n "$PUSH_TOKEN" ] && [ -n "$PUSH_TOKEN_COMMIT" ]; then + log "calling $PUSH_TOKEN commit" + $PUSH_TOKEN commit || err_exit "$PUSH_TOKEN could not commit" + # We cannot know how long the execution of an external command will take. + # Safer to force fetching a new nonce to avoid fatal badNonce error due to nonce validity timeout. + NONCE="" + fi +} + +domain_dns_challenge() { + DNS_CHALLENGE="`printf '%s' "$DOMAIN_TOKEN.$ACCOUNT_THUMB" | openssl dgst -sha256 -binary | base64url`" + if [ -n "$PUSH_TOKEN" ]; then + $PUSH_TOKEN "$1" _acme-challenge."$DOMAIN" "$DNS_CHALLENGE" || err_exit "Could not $1 $CHALLENGE_TYPE type challenge token with value $DNS_CHALLENGE for domain $DOMAIN via $PUSH_TOKEN" + else + printf 'update %s _acme-challenge.%s. 300 IN TXT "%s"\n\n' "$1" "$DOMAIN" "$DNS_CHALLENGE" | + nsupdate || err_exit "Could not $1 $CHALLENGE_TYPE type challenge token with value $DNS_CHALLENGE for domain $DOMAIN via nsupdate" + fi +} + +push_domain_response() { + log "push response for $DOMAIN" + + # do something with DOMAIN, DOMAIN_TOKEN and DOMAIN_RESPONSE + # echo "$DOMAIN_RESPONSE" > "/writeable/location/$DOMAIN/$DOMAIN_TOKEN" + + if [ "$CHALLENGE_TYPE" = "http-01" ]; then + if [ -n "$WEBDIR" ]; then + TOKEN_DIR="`printf "%s" $WEBDIR | sed -e 's/\$DOMAIN/'"$DOMAIN"'/g; s/${DOMAIN}/'"$DOMAIN"'/g'`" + SAVED_UMASK="`umask`" + umask 0022 + printf "%s\n" "$DOMAIN_TOKEN.$ACCOUNT_THUMB" > "$TOKEN_DIR/$DOMAIN_TOKEN" || exit 1 + umask "$SAVED_UMASK" + elif [ -n "$PUSH_TOKEN" ]; then + $PUSH_TOKEN install "$DOMAIN" "$DOMAIN_TOKEN" "$ACCOUNT_THUMB" || err_exit "could not install token for $DOMAIN" + fi + elif [ "$CHALLENGE_TYPE" = "dns-01" ]; then + domain_dns_challenge "add" + else + echo "unsupported challenge type for install token: $CHALLENGE_TYPE" >& 2; exit 1 + fi + + return +} + +remove_domain_response() { + log "remove response for $DOMAIN" + + # do something with DOMAIN and DOMAIN_TOKEN + # rm "/writeable/location/$DOMAIN/$DOMAIN_TOKEN" + + if [ "$CHALLENGE_TYPE" = "http-01" ]; then + if [ -n "$WEBDIR" ]; then + TOKEN_DIR="`printf "%s" $WEBDIR | sed -e 's/\$DOMAIN/'"$DOMAIN"'/g; s/${DOMAIN}/'"$DOMAIN"'/g'`" + rm -f "$TOKEN_DIR/$DOMAIN_TOKEN" + elif [ -n "$PUSH_TOKEN" ]; then + $PUSH_TOKEN remove "$DOMAIN" "$DOMAIN_TOKEN" "$ACCOUNT_THUMB" || exit 1 + fi + elif [ "$CHALLENGE_TYPE" = "dns-01" ]; then + domain_dns_challenge "delete" + else + echo "unsupported challenge type for remove token: $CHALLENGE_TYPE" >& 2; exit 1 + fi + + return +} + +push_response() { + set -- $DOMAIN_DATA + while [ -n "$1" ]; do + DOMAIN="$1" + DOMAIN_URI="$2" + DOMAIN_TOKEN="$3" + DOMAIN_AUTHZ="$4" + + shift 4 + + push_domain_response + done + domain_commit +} + +request_domain_verification() { + log request verification of $DOMAIN + + send_req $DOMAIN_URI '{}' + dbgmsg "Retry-After value in request_domain_verification: `retry_after`" + + if check_http_status 200; then + return + else + unhandled_response "requesting verification of challenge of $DOMAIN" + fi +} + +request_verification() { + set -- $DOMAIN_DATA + + while [ -n "$1" ]; do + DOMAIN="$1" + DOMAIN_URI="$2" + DOMAIN_TOKEN="$3" + DOMAIN_AUTHZ="$4" + + shift 4 + + request_domain_verification + done +} + +domain_status() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*"type":"'"$CHALLENGE_TYPE"'",[^{}]*"status":"\([^"]*\)".*/\1/' +} + +check_verification() { + ALL_VALID=true + + while [ -n "$DOMAIN_DATA" ]; do + sleep 1 + + set -- $DOMAIN_DATA + DOMAIN_DATA="" + + while [ -n "$1" ]; do + DOMAIN="$1" + DOMAIN_URI="$2" + DOMAIN_TOKEN="$3" + DOMAIN_AUTHZ="$4" + + shift 4 + + log check verification of $DOMAIN + + send_req "$DOMAIN_AUTHZ" "" + dbgmsg "Retry-After value in check_verification: `retry_after`" + + if check_http_status 200; then + DOMAIN_STATUS="`domain_status`" + case "$DOMAIN_STATUS" in + valid) + log $DOMAIN is valid + remove_domain_response + ;; + invalid) + echo $DOMAIN: invalid >& 2 + show_error + remove_domain_response + + ALL_VALID=false + ;; + pending) + log $DOMAIN is pending + DOMAIN_DATA="$DOMAIN_DATA $DOMAIN $DOMAIN_URI $DOMAIN_TOKEN $DOMAIN_AUTHZ" + ;; + *) + unhandled_response "checking verification status of $DOMAIN: $DOMAIN_STATUS" + ;; + esac + else + unhandled_response "checking verification status of $DOMAIN" + fi + done + done + domain_commit + + $ALL_VALID || exit 1 + + log checking order + while : ;do + send_req "$CURRENT_ORDER" "" + if check_http_status 200; then + ORDER_STATUS="`order_status`" + case "$ORDER_STATUS" in + ready) + log order is ready + break + ;; + pending) + echo order: "$ORDER_STATUS" >& 2 + sleep 1 + continue + ;; + *) + unhandled_response "checking verification status of order" + ;; + esac + else + unhandled_response "requesting order status verification" + fi + done +} + +# this function generates the csr from the private server key and list of domains + +gen_csr_with_private_key() { + log generate certificate request + + set -- $DOMAINS + + FIRST_DOM="$1" + validate_domain "$FIRST_DOM" || err_exit "invalid domain: $FIRST_DOM" + + ALT_NAME="subjectAltName=DNS:$1" + shift + + for DOMAIN do + validate_domain "$DOMAIN" || err_exit "invalid domain: $DOMAIN" + ALT_NAME="$ALT_NAME,DNS:$DOMAIN" + done + + if [ -r /etc/ssl/openssl.cnf ]; then + cat /etc/ssl/openssl.cnf > "$OPENSSL_CONFIG" + else + cat /etc/pki/tls/openssl.cnf > "$OPENSSL_CONFIG" + fi + echo '[SAN]' >> "$OPENSSL_CONFIG" + echo "$ALT_NAME" >> "$OPENSSL_CONFIG" + + openssl req -new -sha512 -key "$SERVER_KEY" -subj "/CN=$FIRST_DOM" -reqexts SAN -config $OPENSSL_CONFIG \ + > "$TMP_SERVER_CSR" \ + 2> "$OPENSSL_ERR" + handle_openssl_exit $? "creating certificate request" + + pwnedkey_key_check "$SERVER_KEY" "server key" || exit +} + +subject_domain() { + sed -n '/Subject:/ {s/^.*CN=//; s/[,/ ].*$//; p}' "$OPENSSL_OUT" +} + +san_domains() { + sed -n '/X509v3 Subject Alternative Name:/ { n; s/^[ ]*DNS[ ]*:[ ]*//; s/[ ]*,[ ]*DNS[ ]*:[ ]*/ /g; p; q; }' "$OPENSSL_OUT" +} + +csr_extract_domains() { + log "extract domains from certificate signing request" + + if echo "$CADIR" | egrep -i -s -q -e '\.buypass\.(com|no)/' -e '\.letsencrypt\.org/' ;then + # Known ACME servers supporting commonName in the Subject of the CSR + Subject_commonName_support=yes + else + # ACME server(s) do not supporting commonName in the Subject of the CSR + # Typically pebble's case, see https://github.com/letsencrypt/pebble/issues/304 + Subject_commonName_support=no + fi + + openssl req -in "$TMP_SERVER_CSR" -noout -text \ + > "$OPENSSL_OUT" \ + 2> "$OPENSSL_ERR" + handle_openssl_exit $? "reading certificate signing request" + + ALTDOMAINS="`san_domains`" + SUBJDOMAIN="`subject_domain`" + + if [ "$Subject_commonName_support" = yes ] ;then + DOMAINS="$SUBJDOMAIN $ALTDOMAINS" + else + DOMAINS="$ALTDOMAINS" + fi + + pwnedkey_req_check "$TMP_SERVER_CSR" "certificate signing request key" || exit +} + +certificate_extract_domains() { + log "extract domains from certificate" + + openssl x509 -in "$SERVER_CERT" -noout -text \ + > "$OPENSSL_OUT" \ + 2> "$OPENSSL_ERR" + handle_openssl_exit $? "reading certificate" + + DOMAINS="`san_domains`" + if [ -z "$DOMAINS" ]; then + DOMAINS="`subject_domain`" + fi +} + +new_cert() { + sed -e 's/-----BEGIN\( NEW\)\{0,1\} CERTIFICATE REQUEST-----/{"csr":"/; s/-----END\( NEW\)\{0,1\} CERTIFICATE REQUEST-----/"}/;s/+/-/g;s!/!_!g;s/=//g' "$TMP_SERVER_CSR" | tr -d ' \t\r\n' +} + +order_status() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*"status":"\([^"]*\)".*/\1/' +} + +certificate_url() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*"certificate":"\([^"]*\)".*/\1/' +} + +request_certificate(){ + log finalize order + + NEW_CERT="`new_cert`" + send_req "$FINALIZE" "$NEW_CERT" + while : ;do + + if check_http_status 200; then + ORDER_STATUS="`order_status`" + case "$ORDER_STATUS" in + valid) + log order is valid + CERTIFICATE="`certificate_url`" + break + ;; + processing) + log order: "$ORDER_STATUS" + sleep 1 + send_req "$CURRENT_ORDER" "" + continue + ;; + invalid|pending|ready) + echo order: "$ORDER_STATUS" >& 2 + exit 1 + ;; + *) + unhandled_response "checking finalization status of order" + ;; + esac + else + unhandled_response "requesting order finalization" + fi + done + log request certificate + CUR_CHAIN=0 + while [ -n "$CERTIFICATE" ] ;do + send_req "$CERTIFICATE" "" + if check_http_status 200; then + if [ "$CUR_CHAIN" = "$SIGNING_CHAIN_SELECTION" ] ;then + if [ -n "$SERVER_FULL_CHAIN" ] ;then + tr -d '\r' < "$RESP_BODY" | sed -e '/^$/d' > "$SERVER_FULL_CHAIN" + fi + tr -d '\r' < "$RESP_BODY" | + sed -e '1,/^-----END CERTIFICATE-----$/ !d' | sed -e '/^$/d' > "$SERVER_CERT" + tr -d '\r' < "$RESP_BODY" | + sed -e '1,/^-----END CERTIFICATE-----$/d' | sed -e '/^$/d' > "$SERVER_SIGNING_CHAIN" + break + else + CERTIFICATE="`fetch_alternate_link`" + CUR_CHAIN="`expr $CUR_CHAIN + 1`" + if [ -z "$CERTIFICATE" ] ;then + err_exit "No such alternate chain: $SIGNING_CHAIN_SELECTION" 1 + fi + fi + else + unhandled_response "retrieveing certificate" + fi + done +} + +old_cert() { + sed -e 's/-----BEGIN CERTIFICATE-----/{"certificate":"/; s/-----END CERTIFICATE-----/"}/;s/+/-/g;s!/!_!g;s/=//g' "$SERVER_CERT" | tr -d ' \t\r\n' +} + +revoke_certificate(){ + log revoke certificate + + [ -n "$REVOKECERTURL" ] || get_urls + OLD_CERT="`old_cert`" + if [ "$ACCOUNT_KEY" = "$SERVER_KEY" ] ;then + send_req_no_kid "$REVOKECERTURL" "$OLD_CERT" + if check_http_status 400; then + show_error "revoking certificate via server key" + exit 1 + elif check_http_status 200; then + log certificate is revoked via server key + exit 0 + else + unhandled_response "revoking certificate" + fi + else + send_req "$REVOKECERTURL" "$OLD_CERT" + if check_http_status 403 || check_http_status 401; then + if check_acme_error unauthorized ;then + return 1 + else + unhandled_response "revoking certificate" + fi + elif check_http_status 400; then + show_error "revoking certificate via account key" + exit 1 + elif check_http_status 200; then + log certificate is revoked via account key + else + unhandled_response "revoking certificate" + fi + fi +} + +usage() { + cat << EOT +$PROGNAME register [-p] -a account_key -e email +$PROGNAME delete -a account_key +$PROGNAME clrpenda -a account_key +$PROGNAME accountid -a account_key +$PROGNAME thumbprint -a account_key +$PROGNAME revoke {-a account_key|-k server_key} -c signed_crt +$PROGNAME sign -a account_key -k server_key (chain_options) -c signed_crt domain ... +$PROGNAME sign -a account_key -r server_csr (chain_options) -c signed_crt + + -a account_key the private key + -e email the email address assigned to the account key during + the registration + -k server_key the private key of the server certificate + -r server_csr a certificate signing request, which includes the + domains, use e.g. gen-csr.sh to create one + -c signed_crt the location where to store the signed certificate + or retrieve for revocation + + Options for sign operation: + -t selection signing chain selection (number only, default: 0) + -s signing_crt the location, where the intermediate signing + certificate(s) should be stored + default location: {signed_crt}_chain + -f full_chain the location, where the signed certificate with the + intermediate signing certificate(s) should be stored + + ACME server options: + -D URL ACME server directory URL + -4 the connection to the server should use IPv4 + -6 the connection to the server should use IPv6 + + generic flags: + -h this help page + -q quiet operation + -v increase verbosity + + revoke and sign: + -l challenge_type can be http-01 (default) or dns-01 + -w webdir the directory, where the response should be stored + \$DOMAIN will be replaced by the actual domain + the directory will not be created + -P exec the command to call to install the token on a remote + server + -C the command to call to install the token on a remote + server needs the commit feature + clrpenda: clear pending authorizations for the given account + accountid: print account id URI for the given account +EOT +} + +# Here starts the program + +PROGNAME="`basename $0`" + +required_commands + +# temporary files to store input/output of curl or openssl + +trap 'rm -f "$RESP_HEABOD" "$WGET_OUT" "$RESP_HEADER" "$RESP_BODY" "$OPENSSL_CONFIG" "$OPENSSL_IN" "$OPENSSL_OUT" "$OPENSSL_ERR" "$TMP_SERVER_CSR"' 0 1 2 3 13 15 + +# file to store header and body of http response +RESP_HEABOD="`mktemp -t le.$$.resp-heabod.XXXXXX`" +# file to store the output of the wget +WGET_OUT="`mktemp -t le.$$.resp-out.XXXXXX`" +# file to store header of http request +RESP_HEADER="`mktemp -t le.$$.resp-header.XXXXXX`" +# file to store body of http request +RESP_BODY="`mktemp -t le.$$.resp-body.XXXXXX`" +# tmp config for openssl for addional domains +OPENSSL_CONFIG="`mktemp -t le.$$.openssl.cnf.XXXXXX`" +# file to store openssl output +OPENSSL_IN="`mktemp -t le.$$.openssl.in.XXXXXX`" +OPENSSL_OUT="`mktemp -t le.$$.openssl.out.XXXXXX`" +OPENSSL_ERR="`mktemp -t le.$$.openssl.err.XXXXXX`" +# file to store the CSR +TMP_SERVER_CSR="`mktemp -t le.$$.server.csr.XXXXXX`" + +echo 'x\0040x' | egrep -s -q -e 'x x' && ECHOESCFLAG='' || ECHOESCFLAG='-e' + +[ $# -gt 0 ] || err_exit "no action given" + +ACTION="$1" +shift + +SHOW_THUMBPRINT=0 + +case "$ACTION" in + clrpenda|accountid) + while getopts :hqvD:46a: name; do case "$name" in + h) usage; exit 1;; + q) LOGLEVEL=0;; + v) LOGLEVEL="`expr $LOGLEVEL + 1`";; + D) CADIR="$OPTARG";; + 4) IPV_OPTION="-4";; + 6) IPV_OPTION="-6";; + a) ACCOUNT_KEY="$OPTARG";; + ?|:) echo "invalid arguments" >& 2; exit 1;; + esac; done;; + delete) + while getopts :hqvD:46a: name; do case "$name" in + h) usage; exit 1;; + q) LOGLEVEL=0;; + v) LOGLEVEL="`expr $LOGLEVEL + 1`";; + D) CADIR="$OPTARG";; + 4) IPV_OPTION="-4";; + 6) IPV_OPTION="-6";; + a) ACCOUNT_KEY="$OPTARG";; + ?|:) echo "invalid arguments" >& 2; exit 1;; + esac; done;; + register) + while getopts :hqvD:46a:e:p name; do case "$name" in + h) usage; exit 1;; + q) LOGLEVEL=0;; + v) LOGLEVEL="`expr $LOGLEVEL + 1`";; + D) CADIR="$OPTARG";; + 4) IPV_OPTION="-4";; + 6) IPV_OPTION="-6";; + p) SHOW_THUMBPRINT=1;; + a) ACCOUNT_KEY="$OPTARG";; + e) ACCOUNT_EMAIL="$OPTARG";; + ?|:) echo "invalid arguments" >& 2; exit 1;; + esac; done;; + thumbprint) + while getopts :hqva: name; do case "$name" in + h) usage; exit 1;; + q) LOGLEVEL=0;; + v) LOGLEVEL="`expr $LOGLEVEL + 1`";; + a) ACCOUNT_KEY="$OPTARG";; + ?|:) echo "invalid arguments" >& 2; exit 1;; + esac; done;; + revoke) + while getopts :hqvD:46Ca:k:c:w:P:l: name; do case "$name" in + h) usage; exit 1;; + q) LOGLEVEL=0;; + v) LOGLEVEL="`expr $LOGLEVEL + 1`";; + D) CADIR="$OPTARG";; + 4) IPV_OPTION="-4";; + 6) IPV_OPTION="-6";; + C) PUSH_TOKEN_COMMIT=1;; + a) ACCOUNT_KEY="$OPTARG";; + k) SERVER_KEY="$OPTARG";; + c) SERVER_CERT="$OPTARG";; + w) WEBDIR="$OPTARG";; + P) PUSH_TOKEN="$OPTARG";; + l) CHALLENGE_TYPE="$OPTARG";; + ?|:) echo "invalid arguments" >& 2; exit 1;; + esac; done;; + sign) + while getopts :hqvD:46Ca:k:r:f:s:c:w:P:l:t: name; do case "$name" in + h) usage; exit 1;; + q) LOGLEVEL=0;; + v) LOGLEVEL="`expr $LOGLEVEL + 1`";; + D) CADIR="$OPTARG";; + 4) IPV_OPTION="-4";; + 6) IPV_OPTION="-6";; + C) PUSH_TOKEN_COMMIT=1;; + a) ACCOUNT_KEY="$OPTARG";; + k) + if [ -n "$SERVER_CSR" ]; then + echo "server key and server certificate signing request are mutual exclusive" >& 2 + exit 1 + fi + SERVER_KEY="$OPTARG" + ACTION=sign-key + ;; + r) + if [ -n "$SERVER_KEY" ]; then + echo "server key and server certificate signing request are mutual exclusive" >& 2 + exit 1 + fi + SERVER_CSR="$OPTARG" + ACTION=sign-csr + ;; + f) SERVER_FULL_CHAIN="$OPTARG";; + s) SERVER_SIGNING_CHAIN="$OPTARG";; + c) SERVER_CERT="$OPTARG";; + w) WEBDIR="$OPTARG";; + P) PUSH_TOKEN="$OPTARG";; + l) CHALLENGE_TYPE="$OPTARG";; + t) SIGNING_CHAIN_SELECTION="$OPTARG";; + ?|:) echo "invalid arguments" >& 2; exit 1;; + esac; done;; + -h|--help|-?) + usage + exit 1 + ;; + *) + err_exit "invalid action: $ACTION" 1 ;; +esac + +shift $((OPTIND - 1)) + +case "$CHALLENGE_TYPE" in + http-01) + DOMAIN_EXTRA_PAT='' + ;; + dns-01) + DOMAIN_EXTRA_PAT='\(\*\.\)\{0,1\}' + ;; + *) + echo "unsupported challenge type: $CHALLENGE_TYPE" >& 2; exit 1 + ;; +esac + +printf '%s\n' "$SIGNING_CHAIN_SELECTION" | egrep -s -q -e '^[0-9]+$' || + err_exit "Unsupported signing chain selection" 1 + +case "$ACTION" in + accountid) + load_account_key + register_account_key retrieve_kid + printf "account id URI: %s\n" "$KID" + exit;; + + clrpenda) + load_account_key + register_account_key retrieve_kid + clrpenda + exit;; + + delete) + load_account_key + register_account_key nodie + REGISTRATION_URI="`fetch_location`" + delete_account_key + exit 0;; + + register) + load_account_key + [ -z "$ACCOUNT_EMAIL" ] && echo "account email address not given" >& 2 && exit 1 + log "register account" + register_account_key + [ $SHOW_THUMBPRINT -eq 1 ] && printf "account thumbprint: %s\n" "$ACCOUNT_THUMB" + exit 0;; + + thumbprint) + load_account_key no_pwnd_check + printf "account thumbprint: %s\n" "$ACCOUNT_THUMB" + exit 0;; + + revoke) + [ -n "$SERVER_CERT" ] || err_exit "no certificate file given to revoke" + [ -z "$ACCOUNT_KEY" -a -z "$SERVER_KEY" ] && echo "either account key or server key must be given" >& 2 && exit 1 + [ -n "$ACCOUNT_KEY" ] || { log "using server key as account key" ; ACCOUNT_KEY="$SERVER_KEY" ; } + load_account_key + revoke_certificate && exit 0 + certificate_extract_domains;; + + sign) err_exit "neither server key nor server csr given" 1 ;; + + sign-key) + load_account_key + [ -r "$SERVER_KEY" ] || err_exit "could not read server key" + [ -n "$SERVER_CERT" ] || err_exit "no output file given" + + [ "$#" -gt 0 ] || err_exit "domains needed" + DOMAINS=$* + gen_csr_with_private_key + ;; + + sign-csr) + load_account_key + [ -r "$SERVER_CSR" ] || err_exit "could not read certificate signing request" + [ -n "$SERVER_CERT" ] || err_exit "no output file given" + + [ "$#" -eq 0 ] || err_exit "no domains needed" + + # load domains from csr + openssl req -in "$SERVER_CSR" > "$TMP_SERVER_CSR" 2> "$OPENSSL_ERR" + handle_openssl_exit "$?" "copying csr" + csr_extract_domains + ;; + + *) + err_exit "invalid action: $ACTION" 1 ;; +esac + +[ -n "$WEBDIR" ] && [ "$CHALLENGE_TYPE" = "dns-01" ] && + err_exit "webdir option and dns-01 challenge type are mutual exclusive" 1 + +if [ "$CHALLENGE_TYPE" = "http-01" ] ;then + [ -n "$WEBDIR" ] && [ -n "$PUSH_TOKEN" ] && + err_exit "webdir option and command to install the token are mutual exclusive" 1 + [ -z "$WEBDIR" ] && [ -z "$PUSH_TOKEN" ] && + err_exit "either webdir option or command to install the token must be specified" 1 +fi + +[ -z "$PUSH_TOKEN" ] && [ -n "$PUSH_TOKEN_COMMIT" ] && + err_exit "commit feature without command to install the token makes no sense" 1 + +if [ -z "$SERVER_SIGNING_CHAIN" ] ;then + SERVER_SIGNING_CHAIN="$SERVER_CERT"_chain +fi + +request_challenge +push_response +request_verification +check_verification +if [ "$ACTION" = "revoke" ] ;then + revoke_certificate || { show_error "revoking certificate via account key" ; exit 1 ; } +else + request_certificate +fi diff --git a/contrib/acme/letsencrypt_master_local.sh b/contrib/acme/letsencrypt_master_local.sh new file mode 100644 index 0000000..51ac575 --- /dev/null +++ b/contrib/acme/letsencrypt_master_local.sh @@ -0,0 +1,1602 @@ +#!/bin/sh + +# letsencrypt.sh - a simple shell implementation for the acme protocol +# Copyright (C) 2015 Gerhard Heift +# Copyright (C) 2016-2025 Attila Bruncsak +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +#CADIR="https://api.test4.buypass.no/acme/directory" +#CADIR="https://acme-staging-v02.api.letsencrypt.org/directory" + +# Prefix the following line with "# letsencrypt-production-server #", to use +# the staging server of letsencrypt. The staging server has lower rate limits, +# but does not issue valid certificates. To automatically remove the comment +# again on commiting the file, add the filter to your git config by running +# git config filter.production-server.clean misc/filter-production-server + +#CADIR="https://api.buypass.com/acme/directory" +#CADIR="https://acme-v02.api.letsencrypt.org/directory" + +CA_STAGING="https://acme-staging-v02.api.letsencrypt.org/directory" +CA_FINAL="https://acme-v02.api.letsencrypt.org/directory" +## The following line defines the default server to use. +## The staging server has lower rate limits, but does not issue valid certificates. +## There is an option "-s" for 'stating' or 'simulating' to use the staging server +## instead. +# +CADIR=$CA_FINAL + + +# global variables: + +# base64url encoded JSON nonce, generated from Replay-Nonce header +# see gen_protected() +PROTECTED= + +# base64url encoded JSON request object +PAYLOAD= + +# base64url encoded signature of PROTECTED and PAYLOAD +# see also gen_signature() +SIGNATURE= + +# the account key used to send the requests and to verify the domain challenges +ACCOUNT_KEY= + +# the JSON Web Key is the representation of the key as JSON object +ACCOUNT_JWK= + +# the JSON object to specify the signature format +ACCOUNT_ID= + +# the thumbprint is the checksum of the JWK and is used for the challenges +ACCOUNT_THUMB= + +# the private key, which should be signed by the CA +SERVER_KEY= + +# the certificate signing request, which sould be used +SERVER_CSR= + +# the location, where the certificate should be stored +SERVER_CERT= + +# the location, where the certificate with the signing certificate(s) should be stored +SERVER_FULL_CHAIN= + +# the location, where the signing certificate(s) should be stored +SERVER_SIGNING_CHAIN= + +# selection of the signing chain +SIGNING_CHAIN_SELECTION=0 + +# the e-mail address to be used with the account key, only needed if account +# key is not yet registred +ACCOUNT_EMAIL= + +# a list of domains, which should be assigned to the certificate +DOMAINS= + +# a list of domains, challenge uri, token and authorization uri +DOMAIN_DATA= + +# the directory, where to push the response +# $DOMAIN or ${DOMAIN} will be replaced with the actual domain +WEBDIR= + +# the script to be called to push the response to a remote server +PUSH_TOKEN= + +# the script to be called to push the response to a remote server needs the commit feature +PUSH_TOKEN_COMMIT= + +# set the option of the preferred IP family for connecting to the ACME server +IPV_OPTION= + +# the challenge type, can be dns-01 or http-01 (default) +CHALLENGE_TYPE="http-01" + +# the date of the that version +VERSION_DATE="2025-12-01" + +# The meaningful User-Agent to help finding related log entries in the ACME server log +USER_AGENT="bruncsak/ght-acme.sh $VERSION_DATE" + +LOGLEVEL=1 + +# utility functions + +tolower() { + printf '%s' "$*" | tr A-Z a-z +} + +# SDuesterhaupt: 2026-02-22 - Determine whether echo understands -e +# +# default: na +echo 'x\040x' | grep -E -s -q 'x x' && ECHOESCFLAG='' || ECHOESCFLAG='-e' + +HexadecimalStringToOctalEscapeSequence() { +tr '[A-F]' '[a-f]' "$@" | tr -d '\r\n' | +sed -e 's/[^0-9a-f]//g; s/^\(\(..\)\{0,\}\).$/\1/; +s/\([0-9a-f]\)\([0-9a-f]\)/\1_\2/g; s/$/\\c/; +s/_0/o0/g; s/_1/o1/g; s/_2/o2/g; s/_3/o3/g; +s/_4/o4/g; s/_5/o5/g; s/_6/o6/g; s/_7/o7/g; +s/_8/i0/g; s/_9/i1/g; s/_a/i2/g; s/_b/i3/g; +s/_c/i4/g; s/_d/i5/g; s/_e/i6/g; s/_f/i7/g; +s/0o/\\000/g; s/0i/\\001/g; s/1o/\\002/g; s/1i/\\003/g; +s/2o/\\004/g; s/2i/\\005/g; s/3o/\\006/g; s/3i/\\007/g; +s/4o/\\010/g; s/4i/\\011/g; s/5o/\\012/g; s/5i/\\013/g; +s/6o/\\014/g; s/6i/\\015/g; s/7o/\\016/g; s/7i/\\017/g; +s/8o/\\020/g; s/8i/\\021/g; s/9o/\\022/g; s/9i/\\023/g; +s/ao/\\024/g; s/ai/\\025/g; s/bo/\\026/g; s/bi/\\027/g; +s/co/\\030/g; s/ci/\\031/g; s/do/\\032/g; s/di/\\033/g; +s/eo/\\034/g; s/ei/\\035/g; s/fo/\\036/g; s/fi/\\037/g; +' +} + +hex2bin() { + # SDuesterhaupt: 2026-02-22 - No xxd required + # + # default: xxd -r -p + #xxd -r -p + echo $ECHOESCFLAG "`HexadecimalStringToOctalEscapeSequence`" +} + +base64url() { + openssl base64 | tr '+/' '-_' | tr -d '\r\n=' +} + +log() { + if [ "$LOGLEVEL" -gt 0 ]; then + echo "$@" >& 2 + fi +} + +dbgmsg() { + if [ "$LOGLEVEL" -gt 1 ]; then + echo "$@" >& 2 + fi +} + +errmsg() { + echo "$@" >& 2 +} + +err_exit() { + RETCODE="$?" + [ -n "$2" ] && RETCODE="$2" + [ -n "$1" ] && printf "%s\n" "$1" >& 2 + exit "$RETCODE" +} + +required_commands() { + # SDuesterhaupt: 2026-02-22 - No egrep/xxd required + # + # default: REQUIRED_COMMANDS="basename cat cp rm sed grep egrep fgrep tr mktemp expr tail xxd openssl" + REQUIRED_COMMANDS="basename cat cp rm sed grep fgrep tr mktemp expr tail openssl" + + if [ "$USE_WGET" = yes ] ;then + REQUIRED_COMMANDS="$REQUIRED_COMMANDS wget" + else + REQUIRED_COMMANDS="$REQUIRED_COMMANDS curl" + fi + for command in $REQUIRED_COMMANDS ;do + command -v $command > /dev/null || err_exit "The command '$command' is required to run $PROGNAME" + done +} + +validate_domain() { + DOMAIN_IN="$1" + if [ "$DOMAIN_IN" = _ ]; then + return 1 + fi + + DOMAIN_OUT="`printf "%s\n" "$DOMAIN_IN" | sed -e 's/^...$/!/; s/^.\{254,\}$/!/; s/^'"$DOMAIN_EXTRA_PAT"'\([a-zA-Z0-9]\([-a-zA-Z0-9]\{0,61\}[a-zA-Z0-9]\)\{0,1\}\.\)\{1,\}\([a-zA-Z]\([-a-zA-Z0-9]\{0,61\}[a-zA-Z]\)\)$/_/;'`" + + if [ "$DOMAIN_OUT" = _ ]; then + return 0 + else + return 1 + fi +} + +handle_wget_exit() { + WGET_EXIT="$1" + WGET_URI="$2" + + if [ "$WGET_EXIT" -ne 0 -a "$WGET_EXIT" -ne 8 -o -s "$WGET_OUT" ]; then + echo "error while making a web request to \"$WGET_URI\"" >& 2 + echo "wget exit status: $WGET_EXIT" >& 2 + case "$WGET_EXIT" in + # see man wget "EXIT STATUS" + 4) echo " Network failure" >& 2;; + 5) echo " SSL verification failure" >& 2;; + 8) echo " Server issued an error response" >& 2;; + esac + + cat "$WGET_OUT" >& 2 + cat "$RESP_HEABOD" >& 2 + + exit 1 + elif [ "$WGET_EXIT" -eq 8 -a ! -s "$RESP_HEABOD" ] ;then + echo "error while making a web request to \"$WGET_URI\"" >& 2 + echo "wget exit status: $WGET_EXIT" >& 2 + err_exit "Server issued an error response and no error document returned and no --content-on-error flag available. Upgrade your wget or use curl instead." 1 + fi + + tr -d '\r' < "$RESP_HEABOD" | sed -e '/^$/,$d' > "$RESP_HEADER" + tr -d '\r' < "$RESP_HEABOD" | sed -e '1,/^$/d' > "$RESP_BODY" +} + +curl_return_text() +{ + CURL_EXIT="$1" + case "$CURL_EXIT" in + # see man curl "EXIT CODES" + 3) TXT=", malformed URI" ;; + 6) TXT=", could not resolve host" ;; + 7) TXT=", failed to connect" ;; + 28) TXT=", operation timeout" ;; + 35) TXT=", SSL connect error" ;; + 52) TXT=", the server did not reply anything" ;; + 56) TXT=", failure in receiving network data" ;; + *) TXT="" ;; + esac + printf "curl return status: %d%s" "$1" "$TXT" +} + +curl_loop() +{ + CURL_ACTION="$1"; shift + loop_count=0 + pluriel="" + while : ;do + dbgmsg "About making a web request to \"$CURL_ACTION\"" + curl "$@" + CURL_RETURN_CODE="$?" + [ "$loop_count" -ge 20 ] && break + case "$CURL_RETURN_CODE" in + 6) ;; + 7) ;; + 28) ;; + 35) ;; + 52) ;; + 56) ;; + *) break ;; + esac + loop_count=$((loop_count + 1 )) + dbgmsg "While making a web request to \"$CURL_ACTION\" sleeping $loop_count second$pluriel before retry due to `curl_return_text $CURL_RETURN_CODE`" + sleep "$loop_count" + pluriel="s" + done + if [ "$CURL_RETURN_CODE" -ne 0 ] ;then + errmsg "While making a web request to \"$CURL_ACTION\" error exiting due to `curl_return_text $CURL_RETURN_CODE` (retry number: $loop_count)" + exit "$CURL_RETURN_CODE" + else + dbgmsg "While making a web request to \"$CURL_ACTION\" continuing due to `curl_return_text $CURL_RETURN_CODE`" + fi +} + +handle_openssl_exit() { + OPENSSL_EXIT=$1 + OPENSSL_ACTION=$2 + + if [ "$OPENSSL_EXIT" "!=" 0 ]; then + echo "error while $OPENSSL_ACTION" >& 2 + echo "openssl exit status: $OPENSSL_EXIT" >& 2 + cat "$OPENSSL_ERR" >& 2 + exit 1 + fi +} + +fetch_http_status() { + HTTP_STATUS="`sed -e '/^HTTP\// !d; s/^HTTP\/[0-9.]\{1,\} *\([^ ]*\).*$/\1/' "$RESP_HEADER" | tail -n 1`" +} + +check_http_status() { + [ "$HTTP_STATUS" = "$1" ] +} + +check_acme_error() { + fgrep -q "urn:ietf:params:acme:error:$1" "$RESP_BODY" +} + +unhandled_response() { + echo "unhandled response while $1" >& 2 + echo >& 2 + + cat "$RESP_HEADER" "$RESP_BODY" >& 2 + + echo >& 2 + + exit 1 +} + +show_error() { + if [ -n "$1" ]; then + echo "error while $1" >& 2 + fi + + ERR_TYPE="`tr -d '\r\n' < "$RESP_BODY" | sed -e 's/.*"type": *"\([^"]*\)".*/\1/'`" + ERR_DETAILS="`tr -d '\r\n' < "$RESP_BODY" | sed -e 's/.*"detail": *"\([^"]*\)".*/\1/'`" + + + echo " $ERR_DETAILS ($ERR_TYPE)" >& 2 +} + +header_field_value() { + grep -i -e "^$1:.*$2" "$RESP_HEADER" | sed -e 's/^[^:]*: *//' | tr -d '\r\n' +} + +fetch_next_link() { + header_field_value Link ';rel="next"' | sed -s 's/^.*<\(.*\)>.*$/\1/' +} + +fetch_alternate_link() { + header_field_value Link ';rel="alternate"' | sed -s 's/^.*<\(.*\)>.*$/\1/' +} + +fetch_location() { + header_field_value Location +} + +# retrieve the nonce from the response header of the actual request for the forthcomming POST request + +extract_nonce() { + new_nonce="`header_field_value Replay-Nonce`" + if [ -n "$new_nonce" ] ;then + # Log if we had unnecesseraily multiple nonces, but use always the latest nonce + [ -n "$NONCE" ] && log "droping unused nonce: $NONCE" + NONCE="$new_nonce" + dbgmsg " new nonce: $NONCE" + else + dbgmsg "no new nonce" + fi +} + +retry_after() { + header_field_value Retry-After +} + +sleep_retryafter() { + RETRY_AFTER="`retry_after`" + if printf '%s' "$RETRY_AFTER" | grep -E -s -q '^[1-9][0-9]*$' ;then + if [ "$RETRY_AFTER" -gt 61 ] ;then + log "Too big Retry-After header field value: $RETRY_AFTER" + RETRY_AFTER=61 + fi + [ "$RETRY_AFTER" -eq 1 ] && pluriel="" || pluriel="s" + log "sleeping $RETRY_AFTER second$pluriel" + sleep $RETRY_AFTER + else + log "Could not retrieve expected Retry-After header field value: $RETRY_AFTER" + sleep 1 + fi +} + +server_overload() { + if check_http_status 503 && check_acme_error rateLimited ;then + log "busy server rate limit condition" + sleep_retryafter + return 0 + else + return 1 + fi +} + +server_request() { + dbgmsg "server_request: $1 $2" + if [ "$USE_WGET" != yes ] ;then + if [ -n "$2" ] ;then + curl_loop "$1" $CURLEXTRAFLAG -s $IPV_OPTION -A "$USER_AGENT" -D "$RESP_HEADER" -o "$RESP_BODY" -H "Content-type: application/jose+json" -d "$2" "$1" + else + curl_loop "$1" $CURLEXTRAFLAG -s $IPV_OPTION -A "$USER_AGENT" -D "$RESP_HEADER" -o "$RESP_BODY" "$1" + fi + else + if [ -n "$2" ] ;then + wget $WGETEXTRAFLAG -q $IPV_OPTION -U "$USER_AGENT" --retry-connrefused --save-headers $WGETCOEFLAG -O "$RESP_HEABOD" --header="Content-type: application/jose+json" --post-data="$2" "$1" > "$WGET_OUT" 2>& 1 + else + wget $WGETEXTRAFLAG -q $IPV_OPTION -U "$USER_AGENT" --retry-connrefused --save-headers $WGETCOEFLAG -O "$RESP_HEABOD" "$1" > "$WGET_OUT" 2>& 1 + fi + handle_wget_exit $? "$1" + fi + fetch_http_status +} + +request_acme_server() { + while : ;do + server_request "$1" "$2" + extract_nonce + server_overload || return + done +} + +# generate the PROTECTED variable, which contains a nonce retrieved from the +# server in the Replay-Nonce header + +gen_protected(){ + if [ -z "$NONCE" ]; then + dbgmsg "fetch new nonce" + send_get_req "$NEWNONCEURL" + [ -n "$NONCE" ] || err_exit "could not fetch new nonce" + fi + + printf '%s' '{"alg":"RS256",'"$ACCOUNT_ID"',"nonce":"'"$NONCE"'","url":"'"$1"'"}' +} + +# generate the signature for the request + +gen_signature() { + printf '%s' "$1" | + openssl dgst -sha256 -binary -sign "$ACCOUNT_KEY" 2> "$OPENSSL_ERR" + handle_openssl_exit "$?" "signing request" +} + +# helper functions to create the json web key object + +key_get_modulus(){ + openssl rsa -in "$1" -modulus -noout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + handle_openssl_exit $? "extracting account key modulus" + + sed -e 's/^Modulus=//' < "$OPENSSL_OUT" \ + | hex2bin \ + | base64url +} + +key_get_exponent(){ + openssl rsa -in "$1" -text -noout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + handle_openssl_exit $? "extracting account key exponent" + + sed -e '/^publicExponent: / !d; s/^publicExponent: [0-9]* \{1,\}(\(.*\)).*$/\1/;s/^0x\([0-9a-fA-F]\)\(\([0-9a-fA-F][0-9a-fA-F]\)*\)$/0x0\1\2/;s/^0x\(\([0-9a-fA-F][0-9a-fA-F]\)*\)$/\1/' \ + < "$OPENSSL_OUT" \ + | hex2bin \ + | base64url +} + +# make a request to the specified URI +# the payload is signed by the ACCOUNT_KEY +# the response header is stored in the file $RESP_HEADER, the body in the file $RESP_BODY + +send_req_no_kid(){ + URI="$1" + + PAYLOAD="`printf '%s' "$2" | base64url`" + while : ;do + PROTECTED="`gen_protected "$URI" | base64url`" + SIGNATURE="`gen_signature $PROTECTED.$PAYLOAD | base64url`" + + DATA='{"protected":"'"$PROTECTED"'","payload":"'"$PAYLOAD"'","signature":"'"$SIGNATURE"'"}' + + # Use only once a nonce + NONCE="" + + request_acme_server "$URI" "$DATA" + + if ! check_http_status 400; then + return + elif ! check_acme_error badNonce ;then + return + fi + if [ -z "$BAD_NONCE_MSG" ] ;then + BAD_NONCE_MSG=yes + echo "badNonce warning: other than extrem load on the ACME server," >& 2 + echo "this is mostly due to multiple client egress IP addresses," >& 2 + echo "including working IPv4 and IPv6 addresses on dual family systems." >& 2 + echo "In that case as a workaround please try to restrict the egress" >& 2 + echo "IP address with the -4 or -6 command line option on the script." >& 2 + echo "This message is just a warning, continuing safely." >& 2 + fi + # Bad nonce condition. Here we do not sleep to be nice, just loop immediately. + # The error cannot be on the client side, since it is guaranted that we used the latest available nonce. + done +} + +send_req(){ + URI="$1" + + [ -z "$KID" ] && register_account_key retrieve_kid + + send_req_no_kid "$1" "$2" +} + +send_get_req(){ + request_acme_server "$1" +} + +pwncheck(){ + [ "$PWNEDKEY_CHECK" = yes ] || return 0 + + server_request "https://v1.pwnedkeys.com/$1" + + if check_http_status 404; then + log "pwnedkeys.com claims: $2 is not compromised" + return 0 + elif check_http_status 200; then + echo "pwnedkeys.com claims: $2 is compromised, fingerprint: $1" >& 2 + return 1 + else + log "pwncheck unavailable (HTTP $HTTP_STATUS), skipping check" + return 0 + fi + + #unhandled_response "pwncheck" +} + +pkey_hex_digest(){ + openssl dgst -sha256 -hex "$1" > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + handle_openssl_exit $? "public key DER hexdigest" + sed -e 's/^.*= *//' "$OPENSSL_OUT" +} + +pwnedkey_req_check(){ + [ "$PWNEDKEY_CHECK" = no ] && return + openssl req -in "$1" -noout -pubkey > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + handle_openssl_exit $? "extracting request public key" + cp "$OPENSSL_OUT" "$OPENSSL_IN" + if ! openssl pkey -in "$OPENSSL_IN" -pubin -outform der -pubout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" ;then + # On old openssl there is no EC key. There we default to RSA. + openssl rsa -in "$OPENSSL_IN" -pubin -outform der -pubout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + fi + handle_openssl_exit $? "request public key to DER" + cp "$OPENSSL_OUT" "$OPENSSL_IN" + pwncheck "`pkey_hex_digest "$OPENSSL_IN"`" "$2" +} + +pwnedkey_key_check(){ + [ "$PWNEDKEY_CHECK" = no ] && return + if ! openssl ec -in "$1" -outform der -pubout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" ;then + openssl rsa -in "$1" -outform der -pubout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + fi + handle_openssl_exit $? "public key to DER" + cp "$OPENSSL_OUT" "$OPENSSL_IN" + pwncheck "`pkey_hex_digest "$OPENSSL_IN"`" "$2" +} + +# account key handling + +load_account_key(){ + [ -n "$ACCOUNT_KEY" ] || err_exit "no account key specified" + [ -r "$ACCOUNT_KEY" ] || err_exit "could not read account key" + + openssl rsa -in "$ACCOUNT_KEY" -noout > "$OPENSSL_OUT" 2> "$OPENSSL_ERR" + handle_openssl_exit $? "opening account key" + + ACCOUNT_JWK='{"e":"'"`key_get_exponent $ACCOUNT_KEY`"'","kty":"RSA","n":"'"`key_get_modulus $ACCOUNT_KEY`"'"}' + ACCOUNT_ID='"jwk":'"$ACCOUNT_JWK" + ACCOUNT_THUMB="`printf '%s' "$ACCOUNT_JWK" | openssl dgst -sha256 -binary | base64url`" + + if [ -z "$1" ] ;then + if [ "$ACCOUNT_KEY" = "$SERVER_KEY" ] ;then + # We should allow revoking with compromised certificate key too + pwnedkey_key_check "$ACCOUNT_KEY" "server key as account key" || log "revoking certificate with compromised key" + else + pwnedkey_key_check "$ACCOUNT_KEY" "account key" || exit + fi + fi +} + +get_one_url(){ + if ! grep -E -s -q '"'"$1"'"' "$RESP_BODY" ;then + cat "$RESP_BODY" >& 2 + err_exit "Cannot retrieve URL for $1 ACME protocol function from the directory $CADIR" 1 + fi + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*"'"$1"'":"\([^"]*\)".*/\1/' +} + +get_urls(){ + if [ "$USE_WGET" = yes ] ;then + WGETCOEFLAG='--content-on-error' + wget --help | grep -E -s -q "$WGETCOEFLAG" || WGETCOEFLAG='' + fi + + send_get_req "$CADIR" + if ! check_http_status 200 ;then + unhandled_response "fetching directory URLs" + fi + + NEWACCOUNTURL="`get_one_url newAccount`" + REVOKECERTURL="`get_one_url revokeCert`" + KEYCHANGEURL="`get_one_url keyChange`" + NEWNONCEURL="`get_one_url newNonce`" + NEWORDERURL="`get_one_url newOrder`" +} + +orders_url() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e '/"orders":"/ !d; s/.*"orders":"\([^"]*\)".*/\1/' +} + +orders_list() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/^.*"orders":\[\([^]]*\)\].*$/\1/' | tr -d '"' | tr ',' ' ' +} + +register_account_key(){ + + [ -n "$NEWACCOUNTURL" ] || get_urls + if [ -n "$ACCOUNT_EMAIL" ] ;then + NEW_REG='{"termsOfServiceAgreed":true,"contact":["mailto:'"$ACCOUNT_EMAIL"'"]}' + else + NEW_REG='{"onlyReturnExisting":true}' + fi + send_req_no_kid "$NEWACCOUNTURL" "$NEW_REG" + + if check_http_status 200; then + [ "$1" = "retrieve_kid" ] || err_exit "account already registered" + KID="`fetch_location`" + ACCOUNT_ID='"kid":"'"$KID"'"' + ORDERS_URL="`orders_url`" + return + elif check_http_status 201; then + KID="`fetch_location`" + ACCOUNT_ID='"kid":"'"$KID"'"' + ORDERS_URL="`orders_url`" + return + elif check_http_status 409; then + [ "$1" = "nodie" ] || err_exit "account already exists" + elif check_http_status 400 && check_acme_error accountDoesNotExist ;then + show_error "fetching account information" + exit 1 + else + unhandled_response "registering account" + fi +} + +clrpenda() { + ORDERS_LIST="" + while [ -n "$ORDERS_URL" ]; do + send_req "$ORDERS_URL" "" + if check_http_status 200; then + ORDERS_LIST="$ORDERS_LIST `orders_list`" + else + unhandled_response "retrieving orders list" + fi + ORDERS_URL="`fetch_next_link`" + done + + DOMAIN_AUTHZ_LIST="" + set -- $ORDERS_LIST + + for ORDER do + send_req "$ORDER" "" + if check_http_status 200; then + ORDER_STATUS="`order_status`" + if [ "$ORDER_STATUS" = pending ] ;then + DOMAIN_AUTHZ_LIST="$DOMAIN_AUTHZ_LIST `domain_authz_list`" + fi + else + unhandled_response "retrieving order" + fi + done + + # All domain should have that challenge type, even wildcard one + CHALLENGE_TYPE=dns-01 + + set -- $DOMAIN_AUTHZ_LIST + + for DOMAIN_AUTHZ do + send_req "$DOMAIN_AUTHZ" "" + if check_http_status 200; then + DOMAIN="`authz_domain`" + AUTHZ_STATUS="`authz_status`" + if [ "$AUTHZ_STATUS" = pending ] ;then + DOMAIN_URI="`authz_domain_uri`" + log "retrieve challenge for $DOMAIN" + request_domain_verification + fi + else + unhandled_response "retrieve challenge for URL: $DOMAIN_AUTHZ" + fi + done +} + +delete_account_key(){ + log "delete account" + + REG='{"resource":"reg","delete":"true"}' + send_req "$REGISTRATION_URI" "$REG" + + if check_http_status 200; then + return + else + unhandled_response "deleting account" + fi +} + +check_server_domain() { + if [ "$2" = true ] ;then + SERVER_DOMAIN="*.$1" + else + SERVER_DOMAIN="$1" + fi + SERVER_DOMAIN_LOWER="`tolower $SERVER_DOMAIN`" + + set -- $DOMAINS + + for REQ_DOMAIN do + if [ "$SERVER_DOMAIN_LOWER" = "`tolower $REQ_DOMAIN`" ] ;then + return + fi + done + err_exit "ACME server requested authorization for a rogue domain: $SERVER_DOMAIN" 1 +} + +authz_status() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*"status":"\([^"]*\)".*/\1/' +} + +authz_domain() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*"identifier":{"type":"dns","value":"\([^"]*\)"}.*/\1/' +} + +wildcard_domain() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e '/"wildcard":/ !d; s/^.*"wildcard":\([a-z]*\).*$/\1/' +} + +authz_domain_token() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*{\([^}]*"type":"'"$CHALLENGE_TYPE"'"[^}]*\)}.*/\1/; s/.*"token":"\([^"]*\)".*/\1/' +} + +authz_domain_uri() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*{\([^}]*"type":"'"$CHALLENGE_TYPE"'"[^}]*\)}.*/\1/; s/.*"url":"\([^"]*\)".*/\1/' +} + +request_challenge_domain(){ + + send_req "$DOMAIN_AUTHZ" "" + + if check_http_status 200; then + DOMAIN="`authz_domain`" + AUTHZ_STATUS="`authz_status`" + case "$AUTHZ_STATUS" in + valid) + log "authorization is valid for $DOMAIN" + ;; + pending) + check_server_domain "$DOMAIN" "`wildcard_domain`" + DOMAIN_TOKEN="`authz_domain_token`" + DOMAIN_URI="`authz_domain_uri`" + DOMAIN_DATA="$DOMAIN_DATA $DOMAIN $DOMAIN_URI $DOMAIN_TOKEN $DOMAIN_AUTHZ" + log "retrieve challenge for $DOMAIN" + ;; + *) + echo authorization status: "$AUTHZ_STATUS" >& 2 + unhandled_response "checking authorization status for domain $DOMAIN" + ;; + esac + elif check_http_status 400; then + # account not registred? + show_error "retrieve challenge for URL: $DOMAIN_AUTHZ" + exit 1 + elif check_http_status 403; then + # account not registred? + show_error "retrieve challenge for URL: $DOMAIN_AUTHZ" + exit 1 + else + unhandled_response "retrieve challenge for URL: $DOMAIN_AUTHZ" + fi +} + +domain_authz_list() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/^.*"authorizations":\[\([^]]*\)\].*$/\1/' | tr -d '"' | tr ',' ' ' +} + +finalize() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/^.*"finalize":"\([^"]*\).*$/\1/' +} + +request_challenge(){ + log "creating new order" + + set -- $DOMAINS + for DOMAIN do + [ -n "$DOMAIN_ORDERS" ] && DOMAIN_ORDERS="$DOMAIN_ORDERS," + DOMAIN_ORDERS="$DOMAIN_ORDERS"'{"type":"dns","value":"'"$DOMAIN"'"}' + done + + [ -n "$NEWORDERURL" ] || get_urls + NEW_ORDER='{"identifiers":['"$DOMAIN_ORDERS"']}' + send_req "$NEWORDERURL" "$NEW_ORDER" + if check_http_status 201; then + DOMAIN_AUTHZ_LIST="`domain_authz_list`" + FINALIZE="`finalize`" + CURRENT_ORDER="`fetch_location`" + else + unhandled_response "requesting new order for $DOMAINS" + fi + set -- $DOMAIN_AUTHZ_LIST + for DOMAIN_AUTHZ do + request_challenge_domain + done + +} + +domain_commit() { + if [ -n "$PUSH_TOKEN" ] && [ -n "$PUSH_TOKEN_COMMIT" ]; then + log "calling $PUSH_TOKEN commit" + $PUSH_TOKEN commit || err_exit "$PUSH_TOKEN could not commit" + # We cannot know how long the execution of an external command will take. + # Safer to force fetching a new nonce to avoid fatal badNonce error due to nonce validity timeout. + NONCE="" + fi +} + +domain_dns_challenge() { + DNS_CHALLENGE="`printf '%s' "$DOMAIN_TOKEN.$ACCOUNT_THUMB" | openssl dgst -sha256 -binary | base64url`" + if [ -n "$PUSH_TOKEN" ]; then + $PUSH_TOKEN "$1" _acme-challenge."$DOMAIN" "$DNS_CHALLENGE" || err_exit "Could not $1 $CHALLENGE_TYPE type challenge token with value $DNS_CHALLENGE for domain $DOMAIN via $PUSH_TOKEN" + else + # SDuesterhaupt: 2019-12-19 - 'nsupdate' without further options can run only on the dns directly + # External accesses are refused generally. + # + # Additional options: File with TSIG key (DNS_TSIG) + # DNS server (DNS_SERVER) + # Zone which shall be updated (DNS_ZONE) + #printf 'update %s _acme-challenge.%s. 300 IN TXT "%s"\n\n' "$1" "$DOMAIN" "$DNS_CHALLENGE" | + # nsupdate || err_exit "Could not $1 $CHALLENGE_TYPE type challenge token with value $DNS_CHALLENGE for domain $DOMAIN via nsupdate" + + # local Bind/nsupdate integration (adapt paths/names as needed) + DNS_ZONE=${DNS_ZONE:-"example.com."} + DNS_SERVER=${DNS_SERVER:-"127.0.0.1"} + DNS_TSIG=${DNS_TSIG:-"/etc/named.tsig"} + + # build nsupdate script + MyDNSChallengeContent="${MyDNSChallengeContent}server ${DNS_SERVER}\n" + #MyDNSChallengeContent="${MyDNSChallengeContent}debug yes\n" + MyDNSChallengeContent="${MyDNSChallengeContent}zone ${DNS_ZONE}\n" + + # Change DNSSEC from 'secure' to 'insecure' + #MyDNSChallengeContent="${MyDNSChallengeContent}prereq yxrrset $DNS_ZONE. DNS_KEY\n" + #MyDNSChallengeContent="${MyDNSChallengeContent}; Move $DNS_ZONE.zone from secure to insecure temporarily\n" + #MyDNSChallengeContent="${MyDNSChallengeContent}update delete $DNS_ZONE. DNS_KEY\nsend\n" + + case "$1" in + add) + MyDNSChallengeContent="${MyDNSChallengeContent}update add _acme-challenge.${DOMAIN}. 300 IN TXT \"${DNS_CHALLENGE}\"\n" + ;; + delete) + MyDNSChallengeContent="${MyDNSChallengeContent}update delete _acme-challenge.${DOMAIN}. TXT\n" + ;; + *) + err_exit "Unsupported dns-01 action '$1'" + ;; + esac + + #MyDNSChallengeContent="${MyDNSChallengeContent}show\n" + MyDNSChallengeContent="${MyDNSChallengeContent}send\n" + + # DNSSEC freeze/thaw and zone reload + if command -v rndc >/dev/null 2>&1; then + rndc -s "$DNS_SERVER" -k "$DNS_TSIG" freeze "$DNS_ZONE" >/dev/null 2>&1 || true + fi + + printf "%b" "${MyDNSChallengeContent}" > nsupdate.txt + nsupdate -k "$DNS_TSIG" nsupdate.txt \ + || err_exit "Could not $1 $CHALLENGE_TYPE type challenge token with value '$DNS_CHALLENGE' for domain '$DOMAIN' via nsupdate" + + if command -v rndc >/dev/null 2>&1; then + rndc -s "$DNS_SERVER" -k "$DNS_TSIG" thaw "$DNS_ZONE" >/dev/null 2>&1 || true + fi + + rm -f nsupdate.txt + fi +} + +push_domain_response() { + log "push response for $DOMAIN" + + # do something with DOMAIN, DOMAIN_TOKEN and DOMAIN_RESPONSE + # echo "$DOMAIN_RESPONSE" > "/writeable/location/$DOMAIN/$DOMAIN_TOKEN" + + if [ "$CHALLENGE_TYPE" = "http-01" ]; then + if [ -n "$WEBDIR" ]; then + TOKEN_DIR="`printf "%s" $WEBDIR | sed -e 's/\$DOMAIN/'"$DOMAIN"'/g; s/${DOMAIN}/'"$DOMAIN"'/g'`" + SAVED_UMASK="`umask`" + umask 0022 + printf "%s\n" "$DOMAIN_TOKEN.$ACCOUNT_THUMB" > "$TOKEN_DIR/$DOMAIN_TOKEN" || exit 1 + umask "$SAVED_UMASK" + + # Adjust token owner/group and permissions + if command -v stat >/dev/null 2>&1; then + DYNTLS_HTTP_TOKEN_USER=$(stat -c "%U" "$TOKEN_DIR" 2>/dev/null || echo "") + DYNTLS_HTTP_TOKEN_GROUP=$(stat -c "%G" "$TOKEN_DIR" 2>/dev/null || echo "") + if [ -n "$DYNTLS_HTTP_TOKEN_USER" ] && [ -n "$DYNTLS_HTTP_TOKEN_GROUP" ]; then + chown "$DYNTLS_HTTP_TOKEN_USER:$DYNTLS_HTTP_TOKEN_GROUP" "$TOKEN_DIR/$DOMAIN_TOKEN" 2>/dev/null || true + fi + + chmod 660 "$TOKEN_DIR/$DOMAIN_TOKEN" 2>/dev/null || true + fi + elif [ -n "$PUSH_TOKEN" ]; then + $PUSH_TOKEN install "$DOMAIN" "$DOMAIN_TOKEN" "$ACCOUNT_THUMB" || err_exit "could not install token for $DOMAIN" + fi + elif [ "$CHALLENGE_TYPE" = "dns-01" ]; then + domain_dns_challenge "add" + else + echo "unsupported challenge type for install token: $CHALLENGE_TYPE" >& 2; exit 1 + fi + + return +} + +remove_domain_response() { + log "remove response for $DOMAIN" + + # do something with DOMAIN and DOMAIN_TOKEN + # rm "/writeable/location/$DOMAIN/$DOMAIN_TOKEN" + + if [ "$CHALLENGE_TYPE" = "http-01" ]; then + if [ -n "$WEBDIR" ]; then + TOKEN_DIR="`printf "%s" $WEBDIR | sed -e 's/\$DOMAIN/'"$DOMAIN"'/g; s/${DOMAIN}/'"$DOMAIN"'/g'`" + rm -f "$TOKEN_DIR/$DOMAIN_TOKEN" + elif [ -n "$PUSH_TOKEN" ]; then + $PUSH_TOKEN remove "$DOMAIN" "$DOMAIN_TOKEN" "$ACCOUNT_THUMB" || exit 1 + fi + elif [ "$CHALLENGE_TYPE" = "dns-01" ]; then + domain_dns_challenge "delete" + else + echo "unsupported challenge type for remove token: $CHALLENGE_TYPE" >& 2; exit 1 + fi + + return +} + +push_response() { + set -- $DOMAIN_DATA + while [ -n "$1" ]; do + DOMAIN="$1" + DOMAIN_URI="$2" + DOMAIN_TOKEN="$3" + DOMAIN_AUTHZ="$4" + + shift 4 + + push_domain_response + done + domain_commit +} + +request_domain_verification() { + log request verification of $DOMAIN + + send_req $DOMAIN_URI '{}' + dbgmsg "Retry-After value in request_domain_verification: `retry_after`" + + if check_http_status 200; then + return + else + unhandled_response "requesting verification of challenge of $DOMAIN" + fi +} + +request_verification() { + set -- $DOMAIN_DATA + + while [ -n "$1" ]; do + DOMAIN="$1" + DOMAIN_URI="$2" + DOMAIN_TOKEN="$3" + DOMAIN_AUTHZ="$4" + + shift 4 + + request_domain_verification + done +} + +domain_status() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*"type":"'"$CHALLENGE_TYPE"'",[^{}]*"status":"\([^"]*\)".*/\1/' +} + +check_verification() { + ALL_VALID=true + + while [ -n "$DOMAIN_DATA" ]; do + sleep 1 + + set -- $DOMAIN_DATA + DOMAIN_DATA="" + + while [ -n "$1" ]; do + DOMAIN="$1" + DOMAIN_URI="$2" + DOMAIN_TOKEN="$3" + DOMAIN_AUTHZ="$4" + + shift 4 + + log check verification of $DOMAIN + + send_req "$DOMAIN_AUTHZ" "" + dbgmsg "Retry-After value in check_verification: `retry_after`" + + if check_http_status 200; then + DOMAIN_STATUS="`domain_status`" + case "$DOMAIN_STATUS" in + valid) + log $DOMAIN is valid + remove_domain_response + ;; + invalid) + echo $DOMAIN: invalid >& 2 + show_error + remove_domain_response + + ALL_VALID=false + ;; + pending) + log $DOMAIN is pending + DOMAIN_DATA="$DOMAIN_DATA $DOMAIN $DOMAIN_URI $DOMAIN_TOKEN $DOMAIN_AUTHZ" + ;; + *) + unhandled_response "checking verification status of $DOMAIN: $DOMAIN_STATUS" + ;; + esac + else + unhandled_response "checking verification status of $DOMAIN" + fi + done + done + domain_commit + + $ALL_VALID || exit 1 + + log checking order + while : ;do + send_req "$CURRENT_ORDER" "" + if check_http_status 200; then + ORDER_STATUS="`order_status`" + case "$ORDER_STATUS" in + ready) + log order is ready + break + ;; + pending) + echo order: "$ORDER_STATUS" >& 2 + sleep 1 + continue + ;; + *) + unhandled_response "checking verification status of order" + ;; + esac + else + unhandled_response "requesting order status verification" + fi + done +} + +# this function generates the csr from the private server key and list of domains + +gen_csr_with_private_key() { + log generate certificate request + + set -- $DOMAINS + + FIRST_DOM="$1" + validate_domain "$FIRST_DOM" || err_exit "invalid domain: $FIRST_DOM" + + ALT_NAME="subjectAltName=DNS:$1" + shift + + for DOMAIN do + validate_domain "$DOMAIN" || err_exit "invalid domain: $DOMAIN" + ALT_NAME="$ALT_NAME,DNS:$DOMAIN" + done + + if [ -r /etc/ssl/openssl.cnf ]; then + cat /etc/ssl/openssl.cnf > "$OPENSSL_CONFIG" + else + cat /etc/pki/tls/openssl.cnf > "$OPENSSL_CONFIG" + fi + echo '[SAN]' >> "$OPENSSL_CONFIG" + echo "$ALT_NAME" >> "$OPENSSL_CONFIG" + + openssl req -new -sha512 -key "$SERVER_KEY" -subj "/CN=$FIRST_DOM" -reqexts SAN -config $OPENSSL_CONFIG \ + > "$TMP_SERVER_CSR" \ + 2> "$OPENSSL_ERR" + handle_openssl_exit $? "creating certificate request" + + pwnedkey_key_check "$SERVER_KEY" "server key" || exit +} + +subject_domain() { + sed -n '/Subject:/ {s/^.*CN=//; s/[,/ ].*$//; p}' "$OPENSSL_OUT" +} + +san_domains() { + sed -n '/X509v3 Subject Alternative Name:/ { n; s/^[ ]*DNS[ ]*:[ ]*//; s/[ ]*,[ ]*DNS[ ]*:[ ]*/ /g; p; q; }' "$OPENSSL_OUT" +} + +csr_extract_domains() { + log "extract domains from certificate signing request" + + if echo "$CADIR" | grep -E -i -s -q '\.buypass\.(com|no)/' -e '\.letsencrypt\.org/' ;then + # Known ACME servers supporting commonName in the Subject of the CSR + Subject_commonName_support=yes + else + # ACME server(s) do not supporting commonName in the Subject of the CSR + # Typically pebble's case, see https://github.com/letsencrypt/pebble/issues/304 + Subject_commonName_support=no + fi + + openssl req -in "$TMP_SERVER_CSR" -noout -text \ + > "$OPENSSL_OUT" \ + 2> "$OPENSSL_ERR" + handle_openssl_exit $? "reading certificate signing request" + + ALTDOMAINS="`san_domains`" + SUBJDOMAIN="`subject_domain`" + + if [ "$Subject_commonName_support" = yes ] ;then + DOMAINS="$SUBJDOMAIN $ALTDOMAINS" + else + DOMAINS="$ALTDOMAINS" + fi + + pwnedkey_req_check "$TMP_SERVER_CSR" "certificate signing request key" || exit +} + +certificate_extract_domains() { + log "extract domains from certificate" + + openssl x509 -in "$SERVER_CERT" -noout -text \ + > "$OPENSSL_OUT" \ + 2> "$OPENSSL_ERR" + handle_openssl_exit $? "reading certificate" + + DOMAINS="`san_domains`" + if [ -z "$DOMAINS" ]; then + DOMAINS="`subject_domain`" + fi +} + +new_cert() { + sed -e 's/-----BEGIN\( NEW\)\{0,1\} CERTIFICATE REQUEST-----/{"csr":"/; s/-----END\( NEW\)\{0,1\} CERTIFICATE REQUEST-----/"}/;s/+/-/g;s!/!_!g;s/=//g' "$TMP_SERVER_CSR" | tr -d ' \t\r\n' +} + +order_status() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*"status":"\([^"]*\)".*/\1/' +} + +certificate_url() { + tr -d ' \r\n' < "$RESP_BODY" | sed -e 's/.*"certificate":"\([^"]*\)".*/\1/' +} + +request_certificate(){ + log finalize order + + NEW_CERT="`new_cert`" + send_req "$FINALIZE" "$NEW_CERT" + while : ;do + + if check_http_status 200; then + ORDER_STATUS="`order_status`" + case "$ORDER_STATUS" in + valid) + log order is valid + CERTIFICATE="`certificate_url`" + break + ;; + processing) + log order: "$ORDER_STATUS" + sleep 1 + send_req "$CURRENT_ORDER" "" + continue + ;; + invalid|pending|ready) + echo order: "$ORDER_STATUS" >& 2 + exit 1 + ;; + *) + unhandled_response "checking finalization status of order" + ;; + esac + else + unhandled_response "requesting order finalization" + fi + done + log request certificate + CUR_CHAIN=0 + while [ -n "$CERTIFICATE" ] ;do + send_req "$CERTIFICATE" "" + if check_http_status 200; then + if [ "$CUR_CHAIN" = "$SIGNING_CHAIN_SELECTION" ] ;then + if [ -n "$SERVER_FULL_CHAIN" ] ;then + tr -d '\r' < "$RESP_BODY" | sed -e '/^$/d' > "$SERVER_FULL_CHAIN" + fi + tr -d '\r' < "$RESP_BODY" | + sed -e '1,/^-----END CERTIFICATE-----$/ !d' | sed -e '/^$/d' > "$SERVER_CERT" + tr -d '\r' < "$RESP_BODY" | + sed -e '1,/^-----END CERTIFICATE-----$/d' | sed -e '/^$/d' > "$SERVER_SIGNING_CHAIN" + break + else + CERTIFICATE="`fetch_alternate_link`" + CUR_CHAIN="`expr $CUR_CHAIN + 1`" + if [ -z "$CERTIFICATE" ] ;then + err_exit "No such alternate chain: $SIGNING_CHAIN_SELECTION" 1 + fi + fi + else + unhandled_response "retrieveing certificate" + fi + done +} + +old_cert() { + sed -e 's/-----BEGIN CERTIFICATE-----/{"certificate":"/; s/-----END CERTIFICATE-----/"}/;s/+/-/g;s!/!_!g;s/=//g' "$SERVER_CERT" | tr -d ' \t\r\n' +} + +revoke_certificate(){ + log revoke certificate + + [ -n "$REVOKECERTURL" ] || get_urls + OLD_CERT="`old_cert`" + if [ "$ACCOUNT_KEY" = "$SERVER_KEY" ] ;then + send_req_no_kid "$REVOKECERTURL" "$OLD_CERT" + if check_http_status 400; then + show_error "revoking certificate via server key" + exit 1 + elif check_http_status 200; then + log certificate is revoked via server key + exit 0 + else + unhandled_response "revoking certificate" + fi + else + send_req "$REVOKECERTURL" "$OLD_CERT" + if check_http_status 403 || check_http_status 401; then + if check_acme_error unauthorized ;then + return 1 + else + unhandled_response "revoking certificate" + fi + elif check_http_status 400; then + show_error "revoking certificate via account key" + exit 1 + elif check_http_status 200; then + log certificate is revoked via account key + else + unhandled_response "revoking certificate" + fi + fi +} + +usage() { + cat << EOT +$PROGNAME register [-p] -a account_key -e email +$PROGNAME delete -a account_key +$PROGNAME clrpenda -a account_key +$PROGNAME accountid -a account_key +$PROGNAME thumbprint -a account_key +$PROGNAME revoke {-a account_key|-k server_key} -c signed_crt +$PROGNAME sign -a account_key -k server_key (chain_options) -c signed_crt domain ... +$PROGNAME sign -a account_key -r server_csr (chain_options) -c signed_crt + + -a account_key the private key + -e email the email address assigned to the account key during + the registration + -k server_key the private key of the server certificate + -r server_csr a certificate signing request, which includes the + domains, use e.g. gen-csr.sh to create one + -c signed_crt the location where to store the signed certificate + or retrieve for revocation + + Options for sign operation: + -t selection signing chain selection (number only, default: 0) + -s signing_crt the location, where the intermediate signing + certificate(s) should be stored + default location: {signed_crt}_chain + -f full_chain the location, where the signed certificate with the + intermediate signing certificate(s) should be stored + + ACME server options: + -D URL ACME server directory URL + -4 the connection to the server should use IPv4 + -6 the connection to the server should use IPv6 + + generic flags: + -h this help page + -q quiet operation + -v increase verbosity + + revoke and sign: + -l challenge_type can be http-01 (default) or dns-01 + -w webdir the directory, where the response should be stored + \$DOMAIN will be replaced by the actual domain + the directory will not be created + -P exec the command to call to install the token on a remote + server + -C the command to call to install the token on a remote + server needs the commit feature + clrpenda: clear pending authorizations for the given account + accountid: print account id URI for the given account +EOT +} + +# Here starts the program + +PROGNAME="`basename $0`" + +required_commands + +# temporary files to store input/output of curl or openssl + +trap 'rm -f "$RESP_HEABOD" "$WGET_OUT" "$RESP_HEADER" "$RESP_BODY" "$OPENSSL_CONFIG" "$OPENSSL_IN" "$OPENSSL_OUT" "$OPENSSL_ERR" "$TMP_SERVER_CSR"' 0 1 2 3 13 15 + +# file to store header and body of http response +RESP_HEABOD="`mktemp -t le.$$.resp-heabod.XXXXXX`" +# file to store the output of the wget +WGET_OUT="`mktemp -t le.$$.resp-out.XXXXXX`" +# file to store header of http request +RESP_HEADER="`mktemp -t le.$$.resp-header.XXXXXX`" +# file to store body of http request +RESP_BODY="`mktemp -t le.$$.resp-body.XXXXXX`" +# tmp config for openssl for addional domains +OPENSSL_CONFIG="`mktemp -t le.$$.openssl.cnf.XXXXXX`" +# file to store openssl output +OPENSSL_IN="`mktemp -t le.$$.openssl.in.XXXXXX`" +OPENSSL_OUT="`mktemp -t le.$$.openssl.out.XXXXXX`" +OPENSSL_ERR="`mktemp -t le.$$.openssl.err.XXXXXX`" +# file to store the CSR +TMP_SERVER_CSR="`mktemp -t le.$$.server.csr.XXXXXX`" + +echo 'x\0040x' | grep -E -s -q 'x x' && ECHOESCFLAG='' || ECHOESCFLAG='-e' + +[ $# -gt 0 ] || err_exit "no action given" + +ACTION="$1" +shift + +SHOW_THUMBPRINT=0 + +case "$ACTION" in + clrpenda|accountid) + while getopts :hqvD:46a: name; do case "$name" in + h) usage; exit 1;; + q) LOGLEVEL=0;; + v) LOGLEVEL="`expr $LOGLEVEL + 1`";; + D) CADIR="$OPTARG";; + 4) IPV_OPTION="-4";; + 6) IPV_OPTION="-6";; + a) ACCOUNT_KEY="$OPTARG";; + ?|:) echo "invalid arguments" >& 2; exit 1;; + esac; done;; + delete) + while getopts :hqvD:46a: name; do case "$name" in + h) usage; exit 1;; + q) LOGLEVEL=0;; +# s) CADIR="$CA_STAGING";; + v) LOGLEVEL="`expr $LOGLEVEL + 1`";; + D) CADIR="$OPTARG";; + 4) IPV_OPTION="-4";; + 6) IPV_OPTION="-6";; + a) ACCOUNT_KEY="$OPTARG";; + ?|:) echo "invalid arguments" >& 2; exit 1;; + esac; done;; + register) + while getopts :hqsvD:46a:e:p name; do case "$name" in + h) usage; exit 1;; + q) LOGLEVEL=0;; + s) + CADIR="$CA_STAGING" + echo "Use staging server" + ;; + v) LOGLEVEL="`expr $LOGLEVEL + 1`";; + D) CADIR="$OPTARG";; + 4) IPV_OPTION="-4";; + 6) IPV_OPTION="-6";; + p) SHOW_THUMBPRINT=1;; + a) ACCOUNT_KEY="$OPTARG";; + e) ACCOUNT_EMAIL="$OPTARG";; + ?|:) echo "invalid arguments" >& 2; exit 1;; + esac; done;; + thumbprint) + while getopts :hqsva: name; do case "$name" in + h) usage; exit 1;; + q) LOGLEVEL=0;; + s) + CADIR="$CA_STAGING" + echo "Use staging server" + ;; + v) LOGLEVEL="`expr $LOGLEVEL + 1`";; + a) ACCOUNT_KEY="$OPTARG";; + ?|:) echo "invalid arguments" >& 2; exit 1;; + esac; done;; + revoke) + while getopts :hqvD:46Ca:k:c:w:P:l: name; do case "$name" in + h) usage; exit 1;; + q) LOGLEVEL=0;; +# s) CADIR="$CA_STAGING";; + v) LOGLEVEL="`expr $LOGLEVEL + 1`";; + D) CADIR="$OPTARG";; + 4) IPV_OPTION="-4";; + 6) IPV_OPTION="-6";; + C) PUSH_TOKEN_COMMIT=1;; + a) ACCOUNT_KEY="$OPTARG";; + k) SERVER_KEY="$OPTARG";; + c) SERVER_CERT="$OPTARG";; + w) WEBDIR="$OPTARG";; + P) PUSH_TOKEN="$OPTARG";; + l) CHALLENGE_TYPE="$OPTARG";; + ?|:) echo "invalid arguments" >& 2; exit 1;; + esac; done;; + sign) + while getopts :hqsvD:46Ca:k:r:f:s:c:w:P:l:t: name; do case "$name" in + h) usage; exit 1;; + q) LOGLEVEL=0;; + s) + CADIR="$CA_STAGING" + echo "Use staging server" + ;; + v) LOGLEVEL="`expr $LOGLEVEL + 1`";; + D) CADIR="$OPTARG";; + 4) IPV_OPTION="-4";; + 6) IPV_OPTION="-6";; + C) PUSH_TOKEN_COMMIT=1;; + a) ACCOUNT_KEY="$OPTARG";; + k) + if [ -n "$SERVER_CSR" ]; then + echo "server key and server certificate signing request are mutual exclusive" >& 2 + exit 1 + fi + SERVER_KEY="$OPTARG" + ACTION=sign-key + ;; + r) + if [ -n "$SERVER_KEY" ]; then + echo "server key and server certificate signing request are mutual exclusive" >& 2 + exit 1 + fi + SERVER_CSR="$OPTARG" + ACTION=sign-csr + ;; + dns-server|d) + DNS_SERVER="$OPTARG" + ;; + dns-tsig|t) + DNS_TSIG="$OPTARG" + ;; + dns-zone|z) + DNS_ZONE="$OPTARG" + ;; + f) SERVER_FULL_CHAIN="$OPTARG";; + s) SERVER_SIGNING_CHAIN="$OPTARG";; + c) SERVER_CERT="$OPTARG";; + w) WEBDIR="$OPTARG";; + P) PUSH_TOKEN="$OPTARG";; + l) CHALLENGE_TYPE="$OPTARG";; + t) SIGNING_CHAIN_SELECTION="$OPTARG";; + ?|:) echo "invalid arguments" >& 2; exit 1;; + esac; done;; + -h|--help|-?) + usage + exit 1 + ;; + *) + err_exit "invalid action: $ACTION" 1 ;; +esac + +shift $((OPTIND - 1)) + +case "$CHALLENGE_TYPE" in + http-01) + DOMAIN_EXTRA_PAT='' + ;; + dns-01) + DOMAIN_EXTRA_PAT='\(\*\.\)\{0,1\}' + ;; + *) + echo "unsupported challenge type: $CHALLENGE_TYPE" >& 2; exit 1 + ;; +esac + +printf '%s\n' "$SIGNING_CHAIN_SELECTION" | grep -E -s -q '^[0-9]+$' || + err_exit "Unsupported signing chain selection" 1 + +case "$ACTION" in + accountid) + load_account_key + register_account_key retrieve_kid + printf "account id URI: %s\n" "$KID" + exit;; + + clrpenda) + load_account_key + register_account_key retrieve_kid + clrpenda + exit;; + + delete) + load_account_key + register_account_key nodie + REGISTRATION_URI="`fetch_location`" + delete_account_key + exit 0;; + + register) + load_account_key + [ -z "$ACCOUNT_EMAIL" ] && echo "account email address not given" >& 2 && exit 1 + log "register account" + register_account_key + [ $SHOW_THUMBPRINT -eq 1 ] && printf "account thumbprint: %s\n" "$ACCOUNT_THUMB" + exit 0;; + + thumbprint) + load_account_key no_pwnd_check + printf "account thumbprint: %s\n" "$ACCOUNT_THUMB" + exit 0;; + + revoke) + [ -n "$SERVER_CERT" ] || err_exit "no certificate file given to revoke" + [ -z "$ACCOUNT_KEY" -a -z "$SERVER_KEY" ] && echo "either account key or server key must be given" >& 2 && exit 1 + [ -n "$ACCOUNT_KEY" ] || { log "using server key as account key" ; ACCOUNT_KEY="$SERVER_KEY" ; } + load_account_key + revoke_certificate && exit 0 + certificate_extract_domains;; + + sign) err_exit "neither server key nor server csr given" 1 ;; + + sign-key) + load_account_key + [ -r "$SERVER_KEY" ] || err_exit "could not read server key" + [ -n "$SERVER_CERT" ] || err_exit "no output file given" + + [ "$#" -gt 0 ] || err_exit "domains needed" + DOMAINS=$* + gen_csr_with_private_key + ;; + + sign-csr) + load_account_key + [ -r "$SERVER_CSR" ] || err_exit "could not read certificate signing request" + [ -n "$SERVER_CERT" ] || err_exit "no output file given" + + [ "$#" -eq 0 ] || err_exit "no domains needed" + + # load domains from csr + openssl req -in "$SERVER_CSR" > "$TMP_SERVER_CSR" 2> "$OPENSSL_ERR" + handle_openssl_exit "$?" "copying csr" + csr_extract_domains + ;; + + *) + err_exit "invalid action: $ACTION" 1 ;; +esac + +[ -n "$WEBDIR" ] && [ "$CHALLENGE_TYPE" = "dns-01" ] && + err_exit "webdir option and dns-01 challenge type are mutual exclusive" 1 + +if [ "$CHALLENGE_TYPE" = "http-01" ] ;then + [ -n "$WEBDIR" ] && [ -n "$PUSH_TOKEN" ] && + err_exit "webdir option and command to install the token are mutual exclusive" 1 + [ -z "$WEBDIR" ] && [ -z "$PUSH_TOKEN" ] && + err_exit "either webdir option or command to install the token must be specified" 1 +fi + +[ -z "$PUSH_TOKEN" ] && [ -n "$PUSH_TOKEN_COMMIT" ] && + err_exit "commit feature without command to install the token makes no sense" 1 + +if [ -z "$SERVER_SIGNING_CHAIN" ] ;then + SERVER_SIGNING_CHAIN="$SERVER_CERT"_chain +fi + +request_challenge +push_response +request_verification +check_verification +if [ "$ACTION" = "revoke" ] ;then + revoke_certificate || { show_error "revoking certificate via account key" ; exit 1 ; } +else + request_certificate +fi diff --git a/dyntls.sh b/dyntls.sh new file mode 100644 index 0000000..081766e --- /dev/null +++ b/dyntls.sh @@ -0,0 +1,1627 @@ +#!/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 diff --git a/vars.example b/vars.example new file mode 100644 index 0000000..17d1bb6 --- /dev/null +++ b/vars.example @@ -0,0 +1,155 @@ +# dynTLS configuration example +# Copy this file to 'vars' and adjust as needed. +# Warning: do not edit vars.example directly! + +# ------------------------------------------------------------------ +# GENERAL +# ------------------------------------------------------------------ + +# Base directory of dynTLS configuration (defaults to script directory) +#set_var DYNTLS "${0%/*}" + +# Main ACME client program used for certificate operations +# You can set this to any compatible wrapper script or binary +# https://github.com/bruncsak/ght-acme.sh +#DYNTLS_LE_PROGRAM="contrib/acme/letsencrypt.sh" + +# OpenSSL binary (path if not in $PATH) +#set_var DYNTLS_OPENSSL "openssl" + +# Productive mode: +# 0 = staging (test certs from Let's Encrypt staging server) +# 1 = production (real certs) +#set_var DYNTLS_PRODUCTIVE 0 + +# Use symlinks for server key: 1 = link all domains to base key +#set_var DYNTLS_PKI_KEY_LNS 0 # 0=per-domain key, 1=symlink all to base server key + +# ------------------------------------------------------------------ +# PKI DIRECTORIES +# ------------------------------------------------------------------ + +# Root PKI folder (contains httpd structure /certs /private etc.) +#set_var DYNTLS_PKI "/etc/pki" + +# Temporary working dir +#set_var DYNTLS_TMP "$DYNTLS/tmp" + +# HTTP service PKI directories +#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_KEY_DIR "$DYNTLS_PKI_HTTP_DIR/private" +#set_var DYNTLS_PKI_HTTP_CERT_BACKUP_DIR "$DYNTLS_PKI_HTTP_CERT_DIR/backup" + +# Cert/key naming suffixes +#set_var DYNTLS_PKI_CERT_SUFFIX "cert.pem" +#set_var DYNTLS_PKI_FULLCHAIN_SUFFIX "fullchain.pem" +#set_var DYNTLS_PKI_KEY_SUFFIX "key.pem" + +# Base server key file and path +#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" + +# Key algorithm and size +#set_var DYNTLS_PKI_KEY_ALGO rsa +#set_var DYNTLS_PKI_KEY_SIZE 2048 +#set_var DYNTLS_PKI_KEY_CURVE secp384r1 + +# Certificate expiration threshold in days before renewal +#set_var DYNTLS_PKI_CERT_EXPIRE 30 + +# Force regenerating keys on renewal (0=no, 1=yes) +#set_var DYNTLS_PKI_KEY_FORCE_RENEW 0 + +# Days to keep backuped certificates before removal +# Set to 0 to disable automatic deletion of backups +#DYNTLS_BACKUP_EXPIRATION=720 + +# ------------------------------------------------------------------ +# LET'S ENCRYPT / ACME +# ------------------------------------------------------------------ + +# Account key used to register with Let's Encrypt +#set_var DYNTLS_ENCRYPT_ACCOUNTKEY "$DYNTLS/private/letsencrypt_account.key" + +# Token directory for http-01 challenges +#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_HTTPD_DEFAULT_OWNER "apache." + +# Chain CA files for fullchains +#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" + +# DNS validation (dns-01 challenge): server address and TSIG key file name +#set_var DYNTLS_DNS_SERVER "root-dns.example365.tld" +#set_var DYNTLS_DNS_TSIG "tsig.key" +#set_var DYNTLS_DNS_ZONE "" + +# ------------------------------------------------------------------ +# LOGGING +# ------------------------------------------------------------------ + +#set_var DYNTLS_LOG_DIR "/var/log/dyntls" +#set_var DYNTLS_LOG_FILE "$DYNTLS_LOG_DIR/dyntls.log" + +# Log level controls verbosity of logging output: +# 0 = off : Disable all logging output. +# 1 = debug : Detailed diagnostic information for troubleshooting and development. +# Includes variable values, function calls, and detailed execution flow. +# 2 = info : Informational messages about normal operations and milestones. +# Useful for understanding general system behavior without noise. +# 3 = warn : Warnings about potential problems or unusual situations that are not critical. +# Indicates areas that may require attention to prevent errors. +# 4 = error : Errors indicating failures that impact functionality and require investigation. +# 5 = critical : Severe, critical failures that cause system malfunction and need immediate action. +#set_var DYNTLS_LOG_LEVEL "3" + +# ------------------------------------------------------------------ +# DOMAIN LISTS (CN + SANs) +# Each set_list line defines one certificate (CN + optional SANs separated by ':') +# ------------------------------------------------------------------ + +# Example multi-domain certificate (CN + SANs separated by :) +#set_list DYNTLS_DOMAIN_LIST "example365.tld:sub1.example365.tld:sub2.example365.tld" + +# ------------------------------------------------------------------ +# SERVICE LISTS (map CN to a service) +# Format: CN:pki_dir:user.group:chmod:service:owner:restartflag:restart|reload:displayname +# +# pki_dir behavior: +# - If pki_dir does NOT contain a '/' character, it is treated as a +# relative service name and will be created below DYNTLS_PKI. +# Example: pki_dir='postfix' with DYNTLS_PKI='/etc/pki' +# → effective PKI path: /etc/pki/postfix +# +# - If pki_dir contains at least one '/' character, it is treated as +# an absolute path and used as-is without prefixing DYNTLS_PKI. +# Example: pki_dir='/var/opt/container/mosquitto' +# → effective PKI path: /var/opt/container/mosquitto +# ------------------------------------------------------------------ + +# Example service mapping (format: CN:pki_dir:user.group:chmod:service:owner:restartflag:restart|reload:displayname) +#set_list DYNTLS_DOMAINSERVICE_LIST "mail02.example365.tld:postfix:root.root:444:postfix:root:1:restart:Postfix" + +# ------------------------------------------------------------------ +# OPTIONAL COMMAND HOOKS +# ------------------------------------------------------------------ + +# Commands to run before issuing/renewing a cert +#set_list DYNTLS_CMD_PRE_LIST "" + +# Commands to run after successfully issuing/renewing a cert +#set_list DYNTLS_CMD_POST_LIST "" + +# ------------------------------------------------------------------ +# BACKUP AND EXPIRATION +# ------------------------------------------------------------------ + +# Days to keep backuped certificates before removal +# Set to 0 to disable automatic deletion of backups +#set_var DYNTLS_BACKUP_EXPIRATION 360