/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//')
+ _info "_message" "$_message"
+ if [ -z "$_message" ]; then
+ _err "Fail to upload certificate."
+ return 1
+ fi
+
+ for DOMAIN_ID in $DEPLOY_KEYHELP_DOMAIN_ID; do
+ _info "Apply certificate to domain id $DOMAIN_ID"
+ _response=$(_get "$DEPLOY_KEYHELP_BASEURL/index.php?page=domains&action=edit&id=$DOMAIN_ID")
+ cert_value=$(echo "$_response" | grep "$certificate_name" | sed -n 's/.*value="\([^"]*\).*/\1/p')
+ target_type=$(echo "$_response" | grep 'target_type' | grep 'checked' | sed -n 's/.*value="\([^"]*\).*/\1/p')
+ if [ "$target_type" = "directory" ]; then
+ path=$(echo "$_response" | awk '/name="path"/{getline; print}' | sed -n 's/.*value="\([^"]*\).*/\1/p')
+ fi
+ echo "$_response" | grep "is_prefer_https" | grep "checked" >/dev/null
+ if [ $? -eq 0 ]; then
+ is_prefer_https=1
+ else
+ is_prefer_https=0
+ fi
+ echo "$_response" | grep "hsts_enabled" | grep "checked" >/dev/null
+ if [ $? -eq 0 ]; then
+ hsts_enabled=1
+ else
+ hsts_enabled=0
+ fi
+ _debug "cert_value" "$cert_value"
+ if [ -z "$cert_value" ]; then
+ _err "Fail to get certificate id."
+ return 1
+ fi
+
+ _request_body="submit=1&id=$DOMAIN_ID&target_type=$target_type&path=$path&is_prefer_https=$is_prefer_https&hsts_enabled=$hsts_enabled&certificate_type=custom&certificate_id=$cert_value&enforce_https=$DEPLOY_KEYHELP_ENFORCE_HTTPS"
+ _response=$(_post "$_request_body" "$DEPLOY_KEYHELP_BASEURL/index.php?page=domains&action=edit" "" "POST")
+ _message=$(echo "$_response" | grep -A 2 'message-body' | sed -n '/
/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//')
+ _info "_message" "$_message"
+ if [ -z "$_message" ]; then
+ _err "Fail to apply certificate."
+ return 1
+ fi
+ done
+
+ _info "Domain $_cdomain certificate successfully deployed to KeyHelp Domain ID $DEPLOY_KEYHELP_DOMAIN_ID."
+ return 0
+}
diff --git a/deploy/keyhelp_api.sh b/deploy/keyhelp_api.sh
new file mode 100644
index 00000000..75e9d951
--- /dev/null
+++ b/deploy/keyhelp_api.sh
@@ -0,0 +1,86 @@
+#!/usr/bin/env sh
+
+keyhelp_api_deploy() {
+ _cdomain="$1"
+ _ckey="$2"
+ _ccert="$3"
+ _cca="$4"
+
+ _debug _cdomain "$_cdomain"
+ _debug _ckey "$_ckey"
+ _debug _ccert "$_ccert"
+ _debug _cca "$_cca"
+
+ # Read config from saved values or env
+ _getdeployconf DEPLOY_KEYHELP_HOST
+ _getdeployconf DEPLOY_KEYHELP_API_KEY
+
+ _debug DEPLOY_KEYHELP_HOST "$DEPLOY_KEYHELP_HOST"
+ _secure_debug DEPLOY_KEYHELP_API_KEY "$DEPLOY_KEYHELP_API_KEY"
+
+ if [ -z "$DEPLOY_KEYHELP_HOST" ]; then
+ _err "KeyHelp host not found, please define DEPLOY_KEYHELP_HOST."
+ return 1
+ fi
+ if [ -z "$DEPLOY_KEYHELP_API_KEY" ]; then
+ _err "KeyHelp api key not found, please define DEPLOY_KEYHELP_API_KEY."
+ return 1
+ fi
+
+ # Save current values
+ _savedeployconf DEPLOY_KEYHELP_HOST "$DEPLOY_KEYHELP_HOST"
+ _savedeployconf DEPLOY_KEYHELP_API_KEY "$DEPLOY_KEYHELP_API_KEY"
+
+ _request_key="$(tr '\n' ':' <"$_ckey" | sed 's/:/\\n/g')"
+ _request_cert="$(tr '\n' ':' <"$_ccert" | sed 's/:/\\n/g')"
+ _request_ca="$(tr '\n' ':' <"$_cca" | sed 's/:/\\n/g')"
+
+ _request_body="{
+ \"name\": \"$_cdomain\",
+ \"components\": {
+ \"private_key\": \"$_request_key\",
+ \"certificate\": \"$_request_cert\",
+ \"ca_certificate\": \"$_request_ca\"
+ }
+ }"
+
+ _hosts="$(echo "$DEPLOY_KEYHELP_HOST" | tr "," " ")"
+ _keys="$(echo "$DEPLOY_KEYHELP_API_KEY" | tr "," " ")"
+ _i=1
+
+ for _host in $_hosts; do
+ _key="$(_getfield "$_keys" "$_i" " ")"
+ _i="$(_math "$_i" + 1)"
+
+ export _H1="X-API-Key: $_key"
+
+ _put_url="$_host/api/v2/certificates/name/$_cdomain"
+ if _post "$_request_body" "$_put_url" "" "PUT" "application/json" >/dev/null; then
+ _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ else
+ _err "Cannot make PUT request to $_put_url"
+ return 1
+ fi
+
+ if [ "$_code" = "404" ]; then
+ _info "$_cdomain not found, creating new entry at $_host"
+
+ _post_url="$_host/api/v2/certificates"
+ if _post "$_request_body" "$_post_url" "" "POST" "application/json" >/dev/null; then
+ _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ else
+ _err "Cannot make POST request to $_post_url"
+ return 1
+ fi
+ fi
+
+ if _startswith "$_code" "2"; then
+ _info "$_cdomain set at $_host"
+ else
+ _err "HTTP status code is $_code"
+ return 1
+ fi
+ done
+
+ return 0
+}
diff --git a/deploy/multideploy.sh b/deploy/multideploy.sh
new file mode 100644
index 00000000..ef920f64
--- /dev/null
+++ b/deploy/multideploy.sh
@@ -0,0 +1,276 @@
+#!/usr/bin/env sh
+
+################################################################################
+# ACME.sh 3rd party deploy plugin for multiple (same) services
+################################################################################
+# Authors: tomo2403 (creator), https://github.com/tomo2403
+# Updated: 2025-03-01
+# Issues: https://github.com/acmesh-official/acme.sh/issues and mention @tomo2403
+################################################################################
+# Usage (shown values are the examples):
+# 1. Set optional environment variables
+# - export MULTIDEPLOY_FILENAME="multideploy.yaml" - "multideploy.yml" will be automatically used if not set"
+#
+# 2. Run command:
+# acme.sh --deploy --deploy-hook multideploy -d example.com
+################################################################################
+# Dependencies:
+# - yq
+################################################################################
+# Return value:
+# 0 means success, otherwise error.
+################################################################################
+
+MULTIDEPLOY_VERSION="1.0"
+
+# Description: This function handles the deployment of certificates to multiple services.
+# It processes the provided certificate files and deploys them according to the
+# configuration specified in the multideploy file.
+#
+# Parameters:
+# _cdomain - The domain name for which the certificate is issued.
+# _ckey - The private key file for the certificate.
+# _ccert - The certificate file.
+# _cca - The CA (Certificate Authority) file.
+# _cfullchain - The full chain certificate file.
+# _cpfx - The PFX (Personal Information Exchange) file.
+multideploy_deploy() {
+ _cdomain="$1"
+ _ckey="$2"
+ _ccert="$3"
+ _cca="$4"
+ _cfullchain="$5"
+ _cpfx="$6"
+
+ _debug _cdomain "$_cdomain"
+ _debug _ckey "$_ckey"
+ _debug _ccert "$_ccert"
+ _debug _cca "$_cca"
+ _debug _cfullchain "$_cfullchain"
+ _debug _cpfx "$_cpfx"
+
+ MULTIDEPLOY_FILENAME="${MULTIDEPLOY_FILENAME:-$(_getdeployconf MULTIDEPLOY_FILENAME)}"
+ if [ -z "$MULTIDEPLOY_FILENAME" ]; then
+ MULTIDEPLOY_FILENAME="multideploy.yml"
+ _info "MULTIDEPLOY_FILENAME is not set, so I will use 'multideploy.yml'."
+ else
+ _savedeployconf "MULTIDEPLOY_FILENAME" "$MULTIDEPLOY_FILENAME"
+ _debug2 "MULTIDEPLOY_FILENAME" "$MULTIDEPLOY_FILENAME"
+ fi
+
+ if ! file=$(_preprocess_deployfile "$MULTIDEPLOY_FILENAME"); then
+ _err "Failed to preprocess deploy file."
+ return 1
+ fi
+ _debug3 "File" "$file"
+
+ # Deploy to services
+ _deploy_services "$file"
+ _exitCode="$?"
+
+ return "$_exitCode"
+}
+
+# Description:
+# This function preprocesses the deploy file by checking if 'yq' is installed,
+# verifying the existence of the deploy file, and ensuring only one deploy file is present.
+# Arguments:
+# $@ - Posible deploy file names.
+# Usage:
+# _preprocess_deployfile "" "?"
+_preprocess_deployfile() {
+ # Check if yq is installed
+ if ! command -v yq >/dev/null 2>&1; then
+ _err "yq is not installed! Please install yq and try again."
+ return 1
+ fi
+ _debug3 "yq is installed."
+
+ # Check if deploy file exists
+ for file in "$@"; do
+ _debug3 "Checking file" "$DOMAIN_PATH/$file"
+ if [ -f "$DOMAIN_PATH/$file" ]; then
+ _debug3 "File found"
+ if [ -n "$found_file" ]; then
+ _err "Multiple deploy files found. Please keep only one deploy file."
+ return 1
+ fi
+ found_file="$file"
+ else
+ _debug3 "File not found"
+ fi
+ done
+
+ if [ -z "$found_file" ]; then
+ _err "Deploy file not found. Go to https://github.com/acmesh-official/acme.sh/wiki/deployhooks#36-deploying-to-multiple-services-with-the-same-hooks to see how to create one."
+ return 1
+ fi
+ if ! _check_deployfile "$DOMAIN_PATH/$found_file"; then
+ _err "Deploy file is not valid: $DOMAIN_PATH/$found_file"
+ return 1
+ fi
+
+ echo "$DOMAIN_PATH/$found_file"
+}
+
+# Description:
+# This function checks the deploy file for version compatibility and the existence of the specified configuration and services.
+# Arguments:
+# $1 - The path to the deploy configuration file.
+# $2 - The name of the deploy configuration to use.
+# Usage:
+# _check_deployfile ""
+_check_deployfile() {
+ _deploy_file="$1"
+ _debug2 "check: Deploy file" "$_deploy_file"
+
+ # Check version
+ _deploy_file_version=$(yq -r '.version' "$_deploy_file")
+ if [ "$MULTIDEPLOY_VERSION" != "$_deploy_file_version" ]; then
+ _err "As of $PROJECT_NAME $VER, the deploy file needs version $MULTIDEPLOY_VERSION! Your current deploy file is of version $_deploy_file_version."
+ return 1
+ fi
+ _debug2 "check: Deploy file version is compatible: $_deploy_file_version"
+
+ # Extract all services from config
+ _services=$(yq -r '.services[].name' "$_deploy_file")
+
+ if [ -z "$_services" ]; then
+ _err "Config does not have any services to deploy to."
+ return 1
+ fi
+ _debug2 "check: Config has services."
+ echo "$_services" | while read -r _service; do
+ _debug3 " - $_service"
+ done
+
+ # Check if extracted services exist in services list
+ echo "$_services" | while read -r _service; do
+ _debug2 "check: Checking service: $_service"
+ # Check if service exists
+ _service_config=$(yq -r ".services[] | select(.name == \"$_service\")" "$_deploy_file")
+ if [ -z "$_service_config" ] || [ "$_service_config" = "null" ]; then
+ _err "Service '$_service' not found."
+ return 1
+ fi
+
+ _service_hook=$(echo "$_service_config" | yq -r ".hook" -)
+ if [ -z "$_service_hook" ] || [ "$_service_hook" = "null" ]; then
+ _err "Service '$_service' does not have a hook."
+ return 1
+ fi
+
+ _service_environment=$(echo "$_service_config" | yq -r ".environment" -)
+ if [ -z "$_service_environment" ] || [ "$_service_environment" = "null" ]; then
+ _err "Service '$_service' does not have an environment."
+ return 1
+ fi
+ done
+}
+
+# Description: This function takes a list of environment variables in YAML format,
+# parses them, and exports each key-value pair as environment variables.
+# Arguments:
+# $1 - A string containing the list of environment variables in YAML format.
+# Usage:
+# _export_envs "$env_list"
+_export_envs() {
+ _env_list="$1"
+
+ _secure_debug3 "Exporting envs" "$_env_list"
+
+ echo "$_env_list" | yq -r 'to_entries | .[] | .key + "=" + .value' | while IFS='=' read -r _key _value; do
+ # Using eval to expand nested variables in the configuration file
+ _value=$(eval 'echo "'"$_value"'"')
+ _savedeployconf "$_key" "$_value"
+ _secure_debug3 "Saved $_key" "$_value"
+ done
+}
+
+# Description:
+# This function takes a YAML formatted string of environment variables, parses it,
+# and clears each environment variable. It logs the process of clearing each variable.
+#
+# Note: Environment variables for a hook may be optional and differ between
+# services using the same hook.
+# If one service sets optional environment variables and another does not, the
+# variables may persist and affect subsequent deployments.
+# Clearing these variables after each service ensures that only the
+# environment variables explicitly specified for each service in the deploy
+# file are used.
+# Arguments:
+# $1 - A YAML formatted string containing environment variable key-value pairs.
+# Usage:
+# _clear_envs ""
+_clear_envs() {
+ _env_list="$1"
+
+ _secure_debug3 "Clearing envs" "$_env_list"
+ env_pairs=$(echo "$_env_list" | yq -r 'to_entries | .[] | .key + "=" + .value')
+
+ echo "$env_pairs" | while IFS='=' read -r _key _value; do
+ _debug3 "Deleting key" "$_key"
+ _cleardomainconf "SAVED_$_key"
+ unset -v "$_key"
+ done
+}
+
+# Description:
+# This function deploys services listed in the deploy configuration file.
+# Arguments:
+# $1 - The path to the deploy configuration file.
+# $2 - The list of services to deploy.
+# Usage:
+# _deploy_services "" ""
+_deploy_services() {
+ _deploy_file="$1"
+ _debug3 "Deploy file" "$_deploy_file"
+
+ _tempfile=$(mktemp)
+ trap 'rm -f $_tempfile' EXIT
+
+ yq -r '.services[].name' "$_deploy_file" >"$_tempfile"
+ _debug3 "Services" "$(cat "$_tempfile")"
+
+ _failedServices=""
+ _failedCount=0
+ while read -r _service <&3; do
+ _debug2 "Service" "$_service"
+ _hook=$(yq -r ".services[] | select(.name == \"$_service\").hook" "$_deploy_file")
+ _envs=$(yq -r ".services[] | select(.name == \"$_service\").environment" "$_deploy_file")
+
+ _export_envs "$_envs"
+ if ! _deploy_service "$_service" "$_hook"; then
+ _failedServices="$_service, $_failedServices"
+ _failedCount=$((_failedCount + 1))
+ fi
+ _clear_envs "$_envs"
+ done 3<"$_tempfile"
+
+ _debug3 "Failed services" "$_failedServices"
+ _debug2 "Failed count" "$_failedCount"
+ if [ -n "$_failedServices" ]; then
+ _info "$(__red "Deployment failed") for services: $_failedServices"
+ else
+ _debug "All services deployed successfully."
+ fi
+
+ return "$_failedCount"
+}
+
+# Description: Deploys a service using the specified hook.
+# Arguments:
+# $1 - The name of the service to deploy.
+# $2 - The hook to use for deployment.
+# Usage:
+# _deploy_service
+_deploy_service() {
+ _name="$1"
+ _hook="$2"
+
+ _debug2 "SERVICE" "$_name"
+ _debug2 "HOOK" "$_hook"
+
+ _info "$(__green "Deploying") to '$_name' using '$_hook'"
+ _deploy "$_cdomain" "$_hook"
+}
diff --git a/deploy/netlify.sh b/deploy/netlify.sh
new file mode 100644
index 00000000..8d25f74c
--- /dev/null
+++ b/deploy/netlify.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env sh
+
+# Script to deploy certificate to Netlify
+# https://docs.netlify.com/api/get-started/#authentication
+# https://open-api.netlify.com/#tag/sniCertificate
+
+# This deployment required following variables
+# export Netlify_ACCESS_TOKEN="Your Netlify Access Token"
+# export Netlify_SITE_ID="Your Netlify Site ID"
+
+# If have more than one SITE ID
+# export Netlify_SITE_ID="SITE_ID_1 SITE_ID_2"
+
+# returns 0 means success, otherwise error.
+
+######## Public functions #####################
+
+#domain keyfile certfile cafile fullchain
+netlify_deploy() {
+ _cdomain="$1"
+ _ckey="$2"
+ _ccert="$3"
+ _cca="$4"
+ _cfullchain="$5"
+
+ _debug _cdomain "$_cdomain"
+ _debug _ckey "$_ckey"
+ _debug _ccert "$_ccert"
+ _debug _cca "$_cca"
+ _debug _cfullchain "$_cfullchain"
+
+ if [ -z "$Netlify_ACCESS_TOKEN" ]; then
+ _err "Netlify_ACCESS_TOKEN is not defined."
+ return 1
+ else
+ _savedomainconf Netlify_ACCESS_TOKEN "$Netlify_ACCESS_TOKEN"
+ fi
+ if [ -z "$Netlify_SITE_ID" ]; then
+ _err "Netlify_SITE_ID is not defined."
+ return 1
+ else
+ _savedomainconf Netlify_SITE_ID "$Netlify_SITE_ID"
+ fi
+
+ _info "Deploying certificate to Netlify..."
+
+ ## upload certificate
+ string_ccert=$(sed 's/$/\\n/' "$_ccert" | tr -d '\n')
+ string_cca=$(sed 's/$/\\n/' "$_cca" | tr -d '\n')
+ string_key=$(sed 's/$/\\n/' "$_ckey" | tr -d '\n')
+
+ for SITE_ID in $Netlify_SITE_ID; do
+ _request_body="{\"certificate\":\"$string_ccert\",\"key\":\"$string_key\",\"ca_certificates\":\"$string_cca\"}"
+ _debug _request_body "$_request_body"
+ _debug Netlify_ACCESS_TOKEN "$Netlify_ACCESS_TOKEN"
+ export _H1="Authorization: Bearer $Netlify_ACCESS_TOKEN"
+ _response=$(_post "$_request_body" "https://api.netlify.com/api/v1/sites/$SITE_ID/ssl" "" "POST" "application/json")
+
+ if _contains "$_response" "\"error\""; then
+ _err "Error in deploying $_cdomain certificate to Netlify SITE_ID $SITE_ID."
+ _err "$_response"
+ return 1
+ fi
+ _debug response "$_response"
+ _info "Domain $_cdomain certificate successfully deployed to Netlify SITE_ID $SITE_ID."
+ done
+
+ return 0
+}
diff --git a/deploy/panos.sh b/deploy/panos.sh
index a9232e79..c54d21fe 100644
--- a/deploy/panos.sh
+++ b/deploy/panos.sh
@@ -16,6 +16,7 @@
# export PANOS_TEMPLATE="" # Template Name of panorama managed devices
# export PANOS_TEMPLATE_STACK="" # set a Template Stack if certificate should also be pushed automatically
# export PANOS_VSYS="Shared" # name of the vsys to import the certificate
+# export PANOS_CERTNAME="" # use a custom certificate name to work around Panorama's 31-character limit
#
# The script will automatically generate a new API key if
# no key is found, or if a saved key has expired or is invalid.
@@ -89,7 +90,7 @@ deployer() {
if [ "$type" = 'cert' ]; then
panos_url="${panos_url}?type=import"
content="--$delim${nl}Content-Disposition: form-data; name=\"category\"\r\n\r\ncertificate"
- content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"certificate-name\"\r\n\r\n$_cdomain"
+ content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"certificate-name\"\r\n\r\n$_panos_certname"
content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"key\"\r\n\r\n$_panos_key"
content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"format\"\r\n\r\npem"
content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"file\"; filename=\"$(basename "$_cfullchain")\"${nl}Content-Type: application/octet-stream${nl}${nl}$(cat "$_cfullchain")"
@@ -103,11 +104,11 @@ deployer() {
if [ "$type" = 'key' ]; then
panos_url="${panos_url}?type=import"
content="--$delim${nl}Content-Disposition: form-data; name=\"category\"\r\n\r\nprivate-key"
- content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"certificate-name\"\r\n\r\n$_cdomain"
+ content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"certificate-name\"\r\n\r\n$_panos_certname"
content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"key\"\r\n\r\n$_panos_key"
content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"format\"\r\n\r\npem"
content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"passphrase\"\r\n\r\n123456"
- content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"file\"; filename=\"$(basename "$_cdomain.key")\"${nl}Content-Type: application/octet-stream${nl}${nl}$(cat "$_ckey")"
+ content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"file\"; filename=\"$(basename "$_panos_certname.key")\"${nl}Content-Type: application/octet-stream${nl}${nl}$(cat "$_ckey")"
if [ "$_panos_template" ]; then
content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"target-tpl\"\r\n\r\n$_panos_template"
fi
@@ -242,6 +243,15 @@ panos_deploy() {
_getdeployconf PANOS_VSYS
fi
+ # PANOS_CERTNAME
+ if [ "$PANOS_CERTNAME" ]; then
+ _debug "Detected ENV variable PANOS_CERTNAME. Saving to file."
+ _savedeployconf PANOS_CERTNAME "$PANOS_CERTNAME" 1
+ else
+ _debug "Attempting to load variable PANOS_CERTNAME from file."
+ _getdeployconf PANOS_CERTNAME
+ fi
+
#Store variables
_panos_host=$PANOS_HOST
_panos_user=$PANOS_USER
@@ -249,6 +259,7 @@ panos_deploy() {
_panos_template=$PANOS_TEMPLATE
_panos_template_stack=$PANOS_TEMPLATE_STACK
_panos_vsys=$PANOS_VSYS
+ _panos_certname=$PANOS_CERTNAME
#Test API Key if found. If the key is invalid, the variable _panos_key will be unset.
if [ "$_panos_host" ] && [ "$_panos_key" ]; then
@@ -267,6 +278,12 @@ panos_deploy() {
_err "No password found. If this is your first time deploying, please set PANOS_PASS in ENV variables. You can delete it after you have successfully deployed the certs."
return 1
else
+ # Use certificate name based on the first domain on the certificate if no custom certificate name is set
+ if [ -z "$_panos_certname" ]; then
+ _panos_certname="$_cdomain"
+ _savedeployconf PANOS_CERTNAME "$_panos_certname" 1
+ fi
+
# Generate a new API key if no valid API key is found
if [ -z "$_panos_key" ]; then
_debug "**** Generating new PANOS API KEY ****"
diff --git a/deploy/qiniu.sh b/deploy/qiniu.sh
index 02250ed3..3737ed4e 100644
--- a/deploy/qiniu.sh
+++ b/deploy/qiniu.sh
@@ -8,6 +8,8 @@
# export QINIU_CDN_DOMAIN="cdn.example.com"
# If you have more than one domain, just
# export QINIU_CDN_DOMAIN="cdn1.example.com cdn2.example.com"
+# Optional: force HTTPS redirect (default: false)
+# export QINIU_FORCE_HTTPS="true"
QINIU_API_BASE="https://api.qiniu.com"
@@ -44,6 +46,12 @@ qiniu_deploy() {
QINIU_CDN_DOMAIN="$_cdomain"
fi
+ if [ -z "$QINIU_FORCE_HTTPS" ]; then
+ QINIU_FORCE_HTTPS="false"
+ else
+ _savedomainconf QINIU_FORCE_HTTPS "$QINIU_FORCE_HTTPS"
+ fi
+
## upload certificate
string_fullchain=$(sed 's/$/\\n/' "$_cfullchain" | tr -d '\n')
string_key=$(sed 's/$/\\n/' "$_ckey" | tr -d '\n')
@@ -69,7 +77,7 @@ qiniu_deploy() {
_debug certId "$_certId"
## update domain ssl config
- update_body="{\"certid\":$_certId,\"forceHttps\":false}"
+ update_body="{\"certid\":$_certId,\"forceHttps\":$QINIU_FORCE_HTTPS}"
for domain in $QINIU_CDN_DOMAIN; do
update_path="/domain/$domain/httpsconf"
update_access_token="$(_make_access_token "$update_path")"
diff --git a/deploy/strongswan.sh b/deploy/strongswan.sh
index 14567d17..80353c54 100644
--- a/deploy/strongswan.sh
+++ b/deploy/strongswan.sh
@@ -33,7 +33,7 @@ strongswan_deploy() {
return 1
fi
_info _confdir "${_confdir}"
- __deploy_cert "$@" "stroke" "${_confdir}"
+ __deploy_cert "stroke" "${_confdir}" "$@"
${_ipsec} reload
fi
# For modern vici mode
@@ -50,7 +50,7 @@ strongswan_deploy() {
_err "no swanctl config dir is found"
return 1
fi
- __deploy_cert "$@" "vici" "${_confdir}"
+ __deploy_cert "vici" "${_confdir}" "$@"
${_swanctl} --load-creds
fi
if [ -z "${_swanctl}" ] && [ -z "${_ipsec}" ]; then
@@ -63,13 +63,13 @@ strongswan_deploy() {
#################### Private functions below ##################################
__deploy_cert() {
- _cdomain="${1}"
- _ckey="${2}"
- _ccert="${3}"
- _cca="${4}"
- _cfullchain="${5}"
- _swan_mode="${6}"
- _confdir="${7}"
+ _swan_mode="${1}"
+ _confdir="${2}"
+ _cdomain="${3}"
+ _ckey="${4}"
+ _ccert="${5}"
+ _cca="${6}"
+ _cfullchain="${7}"
_debug _cdomain "${_cdomain}"
_debug _ckey "${_ckey}"
_debug _ccert "${_ccert}"
diff --git a/deploy/truenas_ws.sh b/deploy/truenas_ws.sh
index d334853e..df34f927 100644
--- a/deploy/truenas_ws.sh
+++ b/deploy/truenas_ws.sh
@@ -71,7 +71,7 @@ with Client(uri="$_ws_uri") as c:
fullchain = file.read()
with open('$2', 'r') as file:
privatekey = file.read()
- ret = c.call("certificate.create", {"name": "$3", "create_type": "CERTIFICATE_CREATE_IMPORTED", "certificate": fullchain, "privatekey": privatekey, "passphrase": ""}, job=True)
+ ret = c.call("certificate.create", {"name": "$3", "create_type": "CERTIFICATE_CREATE_IMPORTED", "certificate": fullchain, "privatekey": privatekey}, job=True)
print("R:" + str(ret["id"]))
sys.exit(0)
else:
diff --git a/deploy/unifi.sh b/deploy/unifi.sh
index 1f274236..1d13e04f 100644
--- a/deploy/unifi.sh
+++ b/deploy/unifi.sh
@@ -143,8 +143,10 @@ unifi_deploy() {
# correct file ownership according to the directory, the keystore is placed in
_unifi_keystore_dir=$(dirname "${_unifi_keystore}")
- _unifi_keystore_dir_owner=$(find "${_unifi_keystore_dir}" -maxdepth 0 -printf '%u\n')
- _unifi_keystore_owner=$(find "${_unifi_keystore}" -maxdepth 0 -printf '%u\n')
+ # shellcheck disable=SC2012
+ _unifi_keystore_dir_owner=$(ls -ld "${_unifi_keystore_dir}" | awk '{print $3}')
+ # shellcheck disable=SC2012
+ _unifi_keystore_owner=$(ls -l "${_unifi_keystore}" | awk '{print $3}')
if ! [ "${_unifi_keystore_owner}" = "${_unifi_keystore_dir_owner}" ]; then
_debug "Changing keystore owner to ${_unifi_keystore_dir_owner}"
chown "$_unifi_keystore_dir_owner" "${_unifi_keystore}" >/dev/null 2>&1 # fail quietly if we're not running as root
diff --git a/dnsapi/dns_ali.sh b/dnsapi/dns_ali.sh
index 53a82f91..90196c69 100755
--- a/dnsapi/dns_ali.sh
+++ b/dnsapi/dns_ali.sh
@@ -97,12 +97,13 @@ _ali_rest() {
}
_ali_nonce() {
- #_head_n 1 /dev/null && return 0
+ fi
+ printf "%s" "$(date +%s)$$$(date +%N)" | _digest sha256 hex | cut -c 1-32
}
-_timestamp() {
+_ali_timestamp() {
date -u +"%Y-%m-%dT%H%%3A%M%%3A%SZ"
}
@@ -150,7 +151,7 @@ _check_exist_query() {
query=$query'&SignatureMethod=HMAC-SHA1'
query=$query"&SignatureNonce=$(_ali_nonce)"
query=$query'&SignatureVersion=1.0'
- query=$query'&Timestamp='$(_timestamp)
+ query=$query'&Timestamp='$(_ali_timestamp)
query=$query'&TypeKeyWord=TXT'
query=$query'&Version=2015-01-09'
}
@@ -166,7 +167,7 @@ _add_record_query() {
query=$query'&SignatureMethod=HMAC-SHA1'
query=$query"&SignatureNonce=$(_ali_nonce)"
query=$query'&SignatureVersion=1.0'
- query=$query'&Timestamp='$(_timestamp)
+ query=$query'&Timestamp='$(_ali_timestamp)
query=$query'&Type=TXT'
query=$query'&Value='$3
query=$query'&Version=2015-01-09'
@@ -182,7 +183,7 @@ _delete_record_query() {
query=$query'&SignatureMethod=HMAC-SHA1'
query=$query"&SignatureNonce=$(_ali_nonce)"
query=$query'&SignatureVersion=1.0'
- query=$query'&Timestamp='$(_timestamp)
+ query=$query'&Timestamp='$(_ali_timestamp)
query=$query'&Version=2015-01-09'
}
@@ -196,7 +197,7 @@ _describe_records_query() {
query=$query'&SignatureMethod=HMAC-SHA1'
query=$query"&SignatureNonce=$(_ali_nonce)"
query=$query'&SignatureVersion=1.0'
- query=$query'&Timestamp='$(_timestamp)
+ query=$query'&Timestamp='$(_ali_timestamp)
query=$query'&Version=2015-01-09'
}
diff --git a/dnsapi/dns_aws.sh b/dnsapi/dns_aws.sh
index c88c9d9c..b76d69c2 100755
--- a/dnsapi/dns_aws.sh
+++ b/dnsapi/dns_aws.sh
@@ -161,7 +161,7 @@ _get_root() {
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100 | sed 's/\./\\./g')
_debug "Checking domain: $h"
if [ -z "$h" ]; then
- _error "invalid domain"
+ _err "invalid domain"
return 1
fi
diff --git a/dnsapi/dns_cf.sh b/dnsapi/dns_cf.sh
index 736742f3..7b383c43 100755
--- a/dnsapi/dns_cf.sh
+++ b/dnsapi/dns_cf.sh
@@ -92,7 +92,9 @@ dns_cf_add() {
if _contains "$response" "$txtvalue"; then
_info "Added, OK"
return 0
- elif _contains "$response" "The record already exists"; then
+ elif _contains "$response" "The record already exists" ||
+ _contains "$response" "An identical record already exists." ||
+ _contains "$response" '"code":81058'; then
_info "Already exists, OK"
return 0
else
diff --git a/dnsapi/dns_curanet.sh b/dnsapi/dns_curanet.sh
index f57afa1f..0ef03fea 100644
--- a/dnsapi/dns_curanet.sh
+++ b/dnsapi/dns_curanet.sh
@@ -15,7 +15,7 @@ CURANET_REST_URL="https://api.curanet.dk/dns/v1/Domains"
CURANET_AUTH_URL="https://apiauth.dk.team.blue/auth/realms/Curanet/protocol/openid-connect/token"
CURANET_ACCESS_TOKEN=""
-######## Public functions #####################
+######## Public functions ####################
#Usage: dns_curanet_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_curanet_add() {
@@ -154,7 +154,7 @@ _get_root() {
export _H3="Authorization: Bearer $CURANET_ACCESS_TOKEN"
response="$(_get "$CURANET_REST_URL/$h/Records" "" "")"
- if [ ! "$(echo "$response" | _egrep_o "Entity not found")" ]; then
+ if [ ! "$(echo "$response" | _egrep_o "Entity not found|Bad Request")" ]; then
_domain=$h
return 0
fi
diff --git a/dnsapi/dns_efficientip.sh b/dnsapi/dns_efficientip.sh
new file mode 100755
index 00000000..a485849a
--- /dev/null
+++ b/dnsapi/dns_efficientip.sh
@@ -0,0 +1,139 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_efficientip_info='efficientip.com
+Site: https://efficientip.com/
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_efficientip
+Options:
+ EfficientIP_Creds HTTP Basic Authentication credentials. E.g. "username:password"
+ EfficientIP_Server EfficientIP SOLIDserver Management IP address or FQDN.
+ EfficientIP_DNS_Name Name of the DNS smart or server hosting the zone. Optional.
+ EfficientIP_View Name of the DNS view hosting the zone. Optional.
+OptionsAlt:
+ EfficientIP_Token_Key Alternative API token key, prefered over basic authentication.
+ EfficientIP_Token_Secret Alternative API token secret, required when using a token key.
+ EfficientIP_Server EfficientIP SOLIDserver Management IP address or FQDN.
+ EfficientIP_DNS_Name Name of the DNS smart or server hosting the zone. Optional.
+ EfficientIP_View Name of the DNS view hosting the zone. Optional.
+Issues: github.com/acmesh-official/acme.sh/issues/6325
+Author: EfficientIP-Labs
+'
+
+dns_efficientip_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ _info "Using EfficientIP API"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ if { [ -z "${EfficientIP_Creds}" ] && { [ -z "${EfficientIP_Token_Key}" ] || [ -z "${EfficientIP_Token_Secret}" ]; }; } || [ -z "${EfficientIP_Server}" ]; then
+ EfficientIP_Creds=""
+ EfficientIP_Token_Key=""
+ EfficientIP_Token_Secret=""
+ EfficientIP_Server=""
+ _err "You didn't specify any EfficientIP credentials or token or server (EfficientIP_Creds; EfficientIP_Token_Key; EfficientIP_Token_Secret; EfficientIP_Server)."
+ _err "Please set them via EXPORT EfficientIP_Creds=username:password or EXPORT EfficientIP_server=ip/hostname"
+ _err "or if you want to use Token instead EXPORT EfficientIP_Token_Key=yourkey"
+ _err "and EXPORT EfficientIP_Token_Secret=yoursecret"
+ _err "then try again."
+ return 1
+ fi
+
+ if [ -z "${EfficientIP_DNS_Name}" ]; then
+ EfficientIP_DNS_Name=""
+ fi
+
+ EfficientIP_DNSNameEncoded=$(printf "%b" "${EfficientIP_DNS_Name}" | _url_encode)
+
+ if [ -z "${EfficientIP_View}" ]; then
+ EfficientIP_View=""
+ fi
+
+ EfficientIP_ViewEncoded=$(printf "%b" "${EfficientIP_View}" | _url_encode)
+
+ _saveaccountconf EfficientIP_Creds "${EfficientIP_Creds}"
+ _saveaccountconf EfficientIP_Token_Key "${EfficientIP_Token_Key}"
+ _saveaccountconf EfficientIP_Token_Secret "${EfficientIP_Token_Secret}"
+ _saveaccountconf EfficientIP_Server "${EfficientIP_Server}"
+ _saveaccountconf EfficientIP_DNS_Name "${EfficientIP_DNS_Name}"
+ _saveaccountconf EfficientIP_View "${EfficientIP_View}"
+
+ export _H1="Accept-Language:en-US"
+ baseurlnObject="https://${EfficientIP_Server}/rest/dns_rr_add?rr_type=TXT&rr_ttl=300&rr_name=${fulldomain}&rr_value1=${txtvalue}"
+
+ if [ "${EfficientIP_DNSNameEncoded}" != "" ]; then
+ baseurlnObject="${baseurlnObject}&dns_name=${EfficientIP_DNSNameEncoded}"
+ fi
+
+ if [ "${EfficientIP_ViewEncoded}" != "" ]; then
+ baseurlnObject="${baseurlnObject}&dnsview_name=${EfficientIP_ViewEncoded}"
+ fi
+
+ if [ -z "${EfficientIP_Token_Secret}" ] || [ -z "${EfficientIP_Token_Key}" ]; then
+ EfficientIP_CredsEncoded=$(printf "%b" "${EfficientIP_Creds}" | _base64)
+ export _H2="Authorization: Basic ${EfficientIP_CredsEncoded}"
+ else
+ TS=$(date +%s)
+ Sig=$(printf "%b\n$TS\nPOST\n$baseurlnObject" "${EfficientIP_Token_Secret}" | _digest sha3-256 hex)
+ EfficientIP_CredsEncoded=$(printf "%b:%b" "${EfficientIP_Token_Key}" "$Sig")
+ export _H2="Authorization: SDS ${EfficientIP_CredsEncoded}"
+ export _H3="X-SDS-TS: ${TS}"
+ fi
+
+ result="$(_post "" "${baseurlnObject}" "" "POST")"
+
+ if [ "$(echo "${result}" | _egrep_o "ret_oid")" ]; then
+ _info "DNS record successfully created"
+ return 0
+ else
+ _err "Error creating DNS record"
+ _err "${result}"
+ return 1
+ fi
+}
+
+dns_efficientip_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ _info "Using EfficientIP API"
+ _debug fulldomain "${fulldomain}"
+ _debug txtvalue "${txtvalue}"
+
+ EfficientIP_ViewEncoded=$(printf "%b" "${EfficientIP_View}" | _url_encode)
+ EfficientIP_DNSNameEncoded=$(printf "%b" "${EfficientIP_DNS_Name}" | _url_encode)
+ EfficientIP_CredsEncoded=$(printf "%b" "${EfficientIP_Creds}" | _base64)
+
+ export _H1="Accept-Language:en-US"
+
+ baseurlnObject="https://${EfficientIP_Server}/rest/dns_rr_delete?rr_type=TXT&rr_name=$fulldomain&rr_value1=$txtvalue"
+ if [ "${EfficientIP_DNSNameEncoded}" != "" ]; then
+ baseurlnObject="${baseurlnObject}&dns_name=${EfficientIP_DNSNameEncoded}"
+ fi
+
+ if [ "${EfficientIP_ViewEncoded}" != "" ]; then
+ baseurlnObject="${baseurlnObject}&dnsview_name=${EfficientIP_ViewEncoded}"
+ fi
+
+ if [ -z "$EfficientIP_Token_Secret" ] || [ -z "$EfficientIP_Token_Key" ]; then
+ EfficientIP_CredsEncoded=$(printf "%b" "${EfficientIP_Creds}" | _base64)
+ export _H2="Authorization: Basic $EfficientIP_CredsEncoded"
+ else
+ TS=$(date +%s)
+ Sig=$(printf "%b\n$TS\nDELETE\n${baseurlnObject}" "${EfficientIP_Token_Secret}" | _digest sha3-256 hex)
+ EfficientIP_CredsEncoded=$(printf "%b:%b" "${EfficientIP_Token_Key}" "$Sig")
+ export _H2="Authorization: SDS ${EfficientIP_CredsEncoded}"
+ export _H3="X-SDS-TS: $TS"
+ fi
+
+ result="$(_post "" "${baseurlnObject}" "" "DELETE")"
+
+ if [ "$(echo "${result}" | _egrep_o "ret_oid")" ]; then
+ _info "DNS Record successfully deleted"
+ return 0
+ else
+ _err "Error deleting DNS record"
+ _err "${result}"
+ return 1
+ fi
+}
diff --git a/dnsapi/dns_exoscale.sh b/dnsapi/dns_exoscale.sh
old mode 100755
new mode 100644
index 6898ce38..ddd526a4
--- a/dnsapi/dns_exoscale.sh
+++ b/dnsapi/dns_exoscale.sh
@@ -8,9 +8,9 @@ Options:
EXOSCALE_SECRET_KEY API Secret key
'
-EXOSCALE_API=https://api.exoscale.com/dns/v1
+EXOSCALE_API="https://api-ch-gva-2.exoscale.com/v2"
-######## Public functions #####################
+######## Public functions ########
# Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
# Used to add txt record
@@ -18,159 +18,197 @@ dns_exoscale_add() {
fulldomain=$1
txtvalue=$2
- if ! _checkAuth; then
+ _debug "Using Exoscale DNS v2 API"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ if ! _check_auth; then
return 1
fi
- _debug "First detect the root zone"
- if ! _get_root "$fulldomain"; then
- _err "invalid domain"
+ root_domain_id=$(_get_root_domain_id "$fulldomain")
+ if [ -z "$root_domain_id" ]; then
+ _err "Unable to determine root domain ID for $fulldomain"
return 1
fi
+ _debug root_domain_id "$root_domain_id"
- _debug _sub_domain "$_sub_domain"
- _debug _domain "$_domain"
+ # Always get the subdomain part first
+ sub_domain=$(_get_sub_domain "$fulldomain" "$root_domain_id")
+ _debug sub_domain "$sub_domain"
- _info "Adding record"
- if _exoscale_rest POST "domains/$_domain_id/records" "{\"record\":{\"name\":\"$_sub_domain\",\"record_type\":\"TXT\",\"content\":\"$txtvalue\",\"ttl\":120}}" "$_domain_token"; then
- if _contains "$response" "$txtvalue"; then
- _info "Added, OK"
- return 0
- fi
+ # Build the record name properly
+ if [ -z "$sub_domain" ]; then
+ record_name="_acme-challenge"
+ else
+ record_name="_acme-challenge.$sub_domain"
fi
- _err "Add txt record error."
- return 1
+ payload=$(printf '{"name":"%s","type":"TXT","content":"%s","ttl":120}' "$record_name" "$txtvalue")
+ _debug payload "$payload"
+
+ response=$(_exoscale_rest POST "/dns-domain/${root_domain_id}/record" "$payload")
+ if _contains "$response" "\"id\""; then
+ _info "TXT record added successfully."
+ return 0
+ else
+ _err "Error adding TXT record: $response"
+ return 1
+ fi
}
-# Usage: fulldomain txtvalue
-# Used to remove the txt record after validation
dns_exoscale_rm() {
fulldomain=$1
- txtvalue=$2
- if ! _checkAuth; then
+ _debug "Using Exoscale DNS v2 API for removal"
+ _debug fulldomain "$fulldomain"
+
+ if ! _check_auth; then
return 1
fi
- _debug "First detect the root zone"
- if ! _get_root "$fulldomain"; then
- _err "invalid domain"
+ root_domain_id=$(_get_root_domain_id "$fulldomain")
+ if [ -z "$root_domain_id" ]; then
+ _err "Unable to determine root domain ID for $fulldomain"
return 1
fi
- _debug _sub_domain "$_sub_domain"
- _debug _domain "$_domain"
-
- _debug "Getting txt records"
- _exoscale_rest GET "domains/${_domain_id}/records?type=TXT&name=$_sub_domain" "" "$_domain_token"
- if _contains "$response" "\"name\":\"$_sub_domain\"" >/dev/null; then
- _record_id=$(echo "$response" | tr '{' "\n" | grep "\"content\":\"$txtvalue\"" | _egrep_o "\"id\":[^,]+" | _head_n 1 | cut -d : -f 2 | tr -d \")
+ record_name="_acme-challenge"
+ sub_domain=$(_get_sub_domain "$fulldomain" "$root_domain_id")
+ if [ -n "$sub_domain" ]; then
+ record_name="_acme-challenge.$sub_domain"
fi
- if [ -z "$_record_id" ]; then
- _err "Can not get record id to remove."
+ record_id=$(_find_record_id "$root_domain_id" "$record_name")
+ if [ -z "$record_id" ]; then
+ _err "TXT record not found for deletion."
return 1
fi
- _debug "Deleting record $_record_id"
-
- if ! _exoscale_rest DELETE "domains/$_domain_id/records/$_record_id" "" "$_domain_token"; then
- _err "Delete record error."
+ response=$(_exoscale_rest DELETE "/dns-domain/$root_domain_id/record/$record_id")
+ if _contains "$response" "\"state\":\"success\""; then
+ _info "TXT record deleted successfully."
+ return 0
+ else
+ _err "Error deleting TXT record: $response"
return 1
fi
-
- return 0
}
-#################### Private functions below ##################################
+######## Private helpers ########
-_checkAuth() {
+_check_auth() {
EXOSCALE_API_KEY="${EXOSCALE_API_KEY:-$(_readaccountconf_mutable EXOSCALE_API_KEY)}"
EXOSCALE_SECRET_KEY="${EXOSCALE_SECRET_KEY:-$(_readaccountconf_mutable EXOSCALE_SECRET_KEY)}"
-
if [ -z "$EXOSCALE_API_KEY" ] || [ -z "$EXOSCALE_SECRET_KEY" ]; then
- EXOSCALE_API_KEY=""
- EXOSCALE_SECRET_KEY=""
- _err "You don't specify Exoscale application key and application secret yet."
- _err "Please create you key and try again."
+ _err "EXOSCALE_API_KEY and EXOSCALE_SECRET_KEY must be set."
return 1
fi
-
_saveaccountconf_mutable EXOSCALE_API_KEY "$EXOSCALE_API_KEY"
_saveaccountconf_mutable EXOSCALE_SECRET_KEY "$EXOSCALE_SECRET_KEY"
-
return 0
}
-#_acme-challenge.www.domain.com
-#returns
-# _sub_domain=_acme-challenge.www
-# _domain=domain.com
-# _domain_id=sdjkglgdfewsdfg
-# _domain_token=sdjkglgdfewsdfg
-_get_root() {
-
- if ! _exoscale_rest GET "domains"; then
- return 1
- fi
-
+_get_root_domain_id() {
domain=$1
- i=2
- p=1
+ i=1
while true; do
- h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
- _debug h "$h"
- if [ -z "$h" ]; then
- #not valid
- return 1
- fi
-
- if _contains "$response" "\"name\":\"$h\"" >/dev/null; then
- _domain_id=$(echo "$response" | tr '{' "\n" | grep "\"name\":\"$h\"" | _egrep_o "\"id\":[^,]+" | _head_n 1 | cut -d : -f 2 | tr -d \")
- _domain_token=$(echo "$response" | tr '{' "\n" | grep "\"name\":\"$h\"" | _egrep_o "\"token\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \")
- if [ "$_domain_token" ] && [ "$_domain_id" ]; then
- _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
- _domain=$h
- return 0
+ candidate=$(printf "%s" "$domain" | cut -d . -f "${i}-100")
+ [ -z "$candidate" ] && return 1
+ _debug "Trying root domain candidate: $candidate"
+ domains=$(_exoscale_rest GET "/dns-domain")
+ # Extract from dns-domains array
+ result=$(echo "$domains" | _egrep_o '"dns-domains":\[.*\]' | _egrep_o '\{"id":"[^"]*","created-at":"[^"]*","unicode-name":"[^"]*"\}' | while read -r item; do
+ name=$(echo "$item" | _egrep_o '"unicode-name":"[^"]*"' | cut -d'"' -f4)
+ id=$(echo "$item" | _egrep_o '"id":"[^"]*"' | cut -d'"' -f4)
+ if [ "$name" = "$candidate" ]; then
+ echo "$id"
+ break
fi
- return 1
+ done)
+ if [ -n "$result" ]; then
+ echo "$result"
+ return 0
fi
- p=$i
i=$(_math "$i" + 1)
done
- return 1
}
-# returns response
+_get_sub_domain() {
+ fulldomain=$1
+ root_id=$2
+ root_info=$(_exoscale_rest GET "/dns-domain/$root_id")
+ _debug root_info "$root_info"
+ root_name=$(echo "$root_info" | _egrep_o "\"unicode-name\":\"[^\"]*\"" | cut -d\" -f4)
+ sub=${fulldomain%%."$root_name"}
+
+ if [ "$sub" = "_acme-challenge" ]; then
+ echo ""
+ else
+ # Remove _acme-challenge. prefix to get the actual subdomain
+ echo "${sub#_acme-challenge.}"
+ fi
+}
+
+_find_record_id() {
+ root_id=$1
+ name=$2
+ records=$(_exoscale_rest GET "/dns-domain/$root_id/record")
+
+ # Convert search name to lowercase for case-insensitive matching
+ name_lower=$(echo "$name" | tr '[:upper:]' '[:lower:]')
+
+ echo "$records" | _egrep_o '\{[^}]*"name":"[^"]*"[^}]*\}' | while read -r record; do
+ record_name=$(echo "$record" | _egrep_o '"name":"[^"]*"' | cut -d'"' -f4)
+ record_name_lower=$(echo "$record_name" | tr '[:upper:]' '[:lower:]')
+ if [ "$record_name_lower" = "$name_lower" ]; then
+ echo "$record" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d'"' -f4
+ break
+ fi
+ done
+}
+
+_exoscale_sign() {
+ k=$1
+ shift
+ hex_key=$(printf %b "$k" | _hex_dump | tr -d ' ')
+ printf %s "$@" | _hmac sha256 "$hex_key"
+}
+
_exoscale_rest() {
method=$1
- path="$2"
- data="$3"
- token="$4"
- request_url="$EXOSCALE_API/$path"
- _debug "$path"
+ path=$2
+ data=$3
- export _H1="Accept: application/json"
+ url="${EXOSCALE_API}${path}"
+ expiration=$(_math "$(date +%s)" + 300) # 5m from now
- if [ "$token" ]; then
- export _H2="X-DNS-Domain-Token: $token"
- else
- export _H2="X-DNS-Token: $EXOSCALE_API_KEY:$EXOSCALE_SECRET_KEY"
- fi
+ # Build the message with the actual body or empty line
+ message=$(printf "%s %s\n%s\n\n\n%s" "$method" "/v2$path" "$data" "$expiration")
+ signature=$(_exoscale_sign "$EXOSCALE_SECRET_KEY" "$message" | _base64)
+ auth="EXO2-HMAC-SHA256 credential=${EXOSCALE_API_KEY},expires=${expiration},signature=${signature}"
+
+ _debug "API request: $method $url"
+ _debug "Signed message: [$message]"
+ _debug "Authorization header: [$auth]"
+
+ export _H1="Accept: application/json"
+ export _H2="Authorization: ${auth}"
if [ "$data" ] || [ "$method" = "DELETE" ]; then
export _H3="Content-Type: application/json"
_debug data "$data"
- response="$(_post "$data" "$request_url" "" "$method")"
+ response="$(_post "$data" "$url" "" "$method")"
else
- response="$(_get "$request_url" "" "" "$method")"
+ response="$(_get "$url" "" "" "$method")"
fi
- if [ "$?" != "0" ]; then
- _err "error $request_url"
+ # shellcheck disable=SC2181
+ if [ "$?" -ne 0 ]; then
+ _err "error $url"
return 1
fi
_debug2 response "$response"
+ echo "$response"
return 0
}
diff --git a/dnsapi/dns_gandi_livedns.sh b/dnsapi/dns_gandi_livedns.sh
index 0516fee9..aaef07bf 100644
--- a/dnsapi/dns_gandi_livedns.sh
+++ b/dnsapi/dns_gandi_livedns.sh
@@ -23,6 +23,8 @@ dns_gandi_livedns_add() {
fulldomain=$1
txtvalue=$2
+ GANDI_LIVEDNS_KEY="${GANDI_LIVEDNS_KEY:-$(_readaccountconf_mutable GANDI_LIVEDNS_KEY)}"
+ GANDI_LIVEDNS_TOKEN="${GANDI_LIVEDNS_TOKEN:-$(_readaccountconf_mutable GANDI_LIVEDNS_TOKEN)}"
if [ -z "$GANDI_LIVEDNS_KEY" ] && [ -z "$GANDI_LIVEDNS_TOKEN" ]; then
_err "No Token or API key (deprecated) specified for Gandi LiveDNS."
_err "Create your token or key and export it as GANDI_LIVEDNS_KEY or GANDI_LIVEDNS_TOKEN respectively"
@@ -31,11 +33,11 @@ dns_gandi_livedns_add() {
# Keep only one secret in configuration
if [ -n "$GANDI_LIVEDNS_TOKEN" ]; then
- _saveaccountconf GANDI_LIVEDNS_TOKEN "$GANDI_LIVEDNS_TOKEN"
- _clearaccountconf GANDI_LIVEDNS_KEY
+ _saveaccountconf_mutable GANDI_LIVEDNS_TOKEN "$GANDI_LIVEDNS_TOKEN"
+ _clearaccountconf_mutable GANDI_LIVEDNS_KEY
elif [ -n "$GANDI_LIVEDNS_KEY" ]; then
- _saveaccountconf GANDI_LIVEDNS_KEY "$GANDI_LIVEDNS_KEY"
- _clearaccountconf GANDI_LIVEDNS_TOKEN
+ _saveaccountconf_mutable GANDI_LIVEDNS_KEY "$GANDI_LIVEDNS_KEY"
+ _clearaccountconf_mutable GANDI_LIVEDNS_TOKEN
fi
_debug "First detect the root zone"
diff --git a/dnsapi/dns_hetznercloud.sh b/dnsapi/dns_hetznercloud.sh
new file mode 100644
index 00000000..4a7eea90
--- /dev/null
+++ b/dnsapi/dns_hetznercloud.sh
@@ -0,0 +1,593 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_hetznercloud_info='Hetzner Cloud DNS
+Site: Hetzner.com
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_hetznercloud
+Options:
+ HETZNER_TOKEN API token for the Hetzner Cloud DNS API
+Optional:
+ HETZNER_TTL Custom TTL for new TXT rrsets (default 120)
+ HETZNER_API Override API endpoint (default https://api.hetzner.cloud/v1)
+ HETZNER_MAX_ATTEMPTS Number of 1s polls to wait for async actions (default 120)
+Issues: github.com/acmesh-official/acme.sh/issues
+'
+
+HETZNERCLOUD_API_DEFAULT="https://api.hetzner.cloud/v1"
+HETZNERCLOUD_TTL_DEFAULT=120
+HETZNER_MAX_ATTEMPTS_DEFAULT=120
+
+######## Public functions #####################
+
+dns_hetznercloud_add() {
+ fulldomain="$(_idn "${1}")"
+ txtvalue="${2}"
+
+ _info "Using Hetzner Cloud DNS API to add record"
+
+ if ! _hetznercloud_init; then
+ return 1
+ fi
+
+ if ! _hetznercloud_prepare_zone "${fulldomain}"; then
+ _err "Unable to determine Hetzner Cloud zone for ${fulldomain}"
+ return 1
+ fi
+
+ if ! _hetznercloud_get_rrset; then
+ return 1
+ fi
+
+ if [ "${_hetznercloud_last_http_code}" = "200" ]; then
+ if _hetznercloud_rrset_contains_value "${txtvalue}"; then
+ _info "TXT record already present; nothing to do."
+ return 0
+ fi
+ elif [ "${_hetznercloud_last_http_code}" != "404" ]; then
+ _hetznercloud_log_http_error "Failed to query existing TXT rrset" "${_hetznercloud_last_http_code}"
+ return 1
+ fi
+
+ add_payload="$(_hetznercloud_build_add_payload "${txtvalue}")"
+ if [ -z "${add_payload}" ]; then
+ _err "Failed to build request payload."
+ return 1
+ fi
+
+ if ! _hetznercloud_api POST "${_hetznercloud_rrset_action_add}" "${add_payload}"; then
+ return 1
+ fi
+
+ case "${_hetznercloud_last_http_code}" in
+ 200 | 201 | 202 | 204)
+ if ! _hetznercloud_handle_action_response "TXT record add"; then
+ return 1
+ fi
+ _info "Hetzner Cloud TXT record added."
+ return 0
+ ;;
+ 401 | 403)
+ _err "Hetzner Cloud DNS API authentication failed (HTTP ${_hetznercloud_last_http_code}). Check HETZNER_TOKEN for the new API."
+ _hetznercloud_log_http_error "" "${_hetznercloud_last_http_code}"
+ return 1
+ ;;
+ 409 | 422)
+ _hetznercloud_log_http_error "Hetzner Cloud DNS rejected the add_records request" "${_hetznercloud_last_http_code}"
+ return 1
+ ;;
+ *)
+ _hetznercloud_log_http_error "Hetzner Cloud DNS add_records request failed" "${_hetznercloud_last_http_code}"
+ return 1
+ ;;
+ esac
+}
+
+dns_hetznercloud_rm() {
+ fulldomain="$(_idn "${1}")"
+ txtvalue="${2}"
+
+ _info "Using Hetzner Cloud DNS API to remove record"
+
+ if ! _hetznercloud_init; then
+ return 1
+ fi
+
+ if ! _hetznercloud_prepare_zone "${fulldomain}"; then
+ _err "Unable to determine Hetzner Cloud zone for ${fulldomain}"
+ return 1
+ fi
+
+ if ! _hetznercloud_get_rrset; then
+ return 1
+ fi
+
+ if [ "${_hetznercloud_last_http_code}" = "404" ]; then
+ _info "TXT rrset does not exist; nothing to remove."
+ return 0
+ fi
+
+ if [ "${_hetznercloud_last_http_code}" != "200" ]; then
+ _hetznercloud_log_http_error "Failed to query existing TXT rrset" "${_hetznercloud_last_http_code}"
+ return 1
+ fi
+
+ if _hetznercloud_rrset_contains_value "${txtvalue}"; then
+ remove_payload="$(_hetznercloud_build_remove_payload "${txtvalue}")"
+ if [ -z "${remove_payload}" ]; then
+ _err "Failed to build remove_records payload."
+ return 1
+ fi
+ if ! _hetznercloud_api POST "${_hetznercloud_rrset_action_remove}" "${remove_payload}"; then
+ return 1
+ fi
+ case "${_hetznercloud_last_http_code}" in
+ 200 | 201 | 202 | 204)
+ if ! _hetznercloud_handle_action_response "TXT record remove"; then
+ return 1
+ fi
+ _info "Hetzner Cloud TXT record removed."
+ return 0
+ ;;
+ 401 | 403)
+ _err "Hetzner Cloud DNS API authentication failed (HTTP ${_hetznercloud_last_http_code}). Check HETZNER_TOKEN for the new API."
+ _hetznercloud_log_http_error "" "${_hetznercloud_last_http_code}"
+ return 1
+ ;;
+ 404)
+ _info "TXT rrset already absent after remove action."
+ return 0
+ ;;
+ 409 | 422)
+ _hetznercloud_log_http_error "Hetzner Cloud DNS rejected the remove_records request" "${_hetznercloud_last_http_code}"
+ return 1
+ ;;
+ *)
+ _hetznercloud_log_http_error "Hetzner Cloud DNS remove_records request failed" "${_hetznercloud_last_http_code}"
+ return 1
+ ;;
+ esac
+ else
+ _info "TXT value not present; nothing to remove."
+ return 0
+ fi
+}
+
+#################### Private functions ##################################
+
+_hetznercloud_init() {
+ HETZNER_TOKEN="${HETZNER_TOKEN:-$(_readaccountconf_mutable HETZNER_TOKEN)}"
+ if [ -z "${HETZNER_TOKEN}" ]; then
+ _err "The environment variable HETZNER_TOKEN must be set for the Hetzner Cloud DNS API."
+ return 1
+ fi
+ HETZNER_TOKEN=$(echo "${HETZNER_TOKEN}" | tr -d '"')
+ _saveaccountconf_mutable HETZNER_TOKEN "${HETZNER_TOKEN}"
+
+ HETZNER_API="${HETZNER_API:-$(_readaccountconf_mutable HETZNER_API)}"
+ if [ -z "${HETZNER_API}" ]; then
+ HETZNER_API="${HETZNERCLOUD_API_DEFAULT}"
+ fi
+ _saveaccountconf_mutable HETZNER_API "${HETZNER_API}"
+
+ HETZNER_TTL="${HETZNER_TTL:-$(_readaccountconf_mutable HETZNER_TTL)}"
+ if [ -z "${HETZNER_TTL}" ]; then
+ HETZNER_TTL="${HETZNERCLOUD_TTL_DEFAULT}"
+ fi
+ ttl_check=$(printf "%s" "${HETZNER_TTL}" | tr -d '0-9')
+ if [ -n "${ttl_check}" ]; then
+ _err "HETZNER_TTL must be an integer value."
+ return 1
+ fi
+ _saveaccountconf_mutable HETZNER_TTL "${HETZNER_TTL}"
+
+ HETZNER_MAX_ATTEMPTS="${HETZNER_MAX_ATTEMPTS:-$(_readaccountconf_mutable HETZNER_MAX_ATTEMPTS)}"
+ if [ -z "${HETZNER_MAX_ATTEMPTS}" ]; then
+ HETZNER_MAX_ATTEMPTS="${HETZNER_MAX_ATTEMPTS_DEFAULT}"
+ fi
+ attempts_check=$(printf "%s" "${HETZNER_MAX_ATTEMPTS}" | tr -d '0-9')
+ if [ -n "${attempts_check}" ]; then
+ _err "HETZNER_MAX_ATTEMPTS must be an integer value."
+ return 1
+ fi
+ _saveaccountconf_mutable HETZNER_MAX_ATTEMPTS "${HETZNER_MAX_ATTEMPTS}"
+
+ return 0
+}
+
+_hetznercloud_prepare_zone() {
+ _hetznercloud_zone_id=""
+ _hetznercloud_zone_name=""
+ _hetznercloud_zone_name_lc=""
+ _hetznercloud_rr_name=""
+ _hetznercloud_rrset_path=""
+ _hetznercloud_rrset_action_add=""
+ _hetznercloud_rrset_action_remove=""
+ fulldomain_lc=$(printf "%s" "${1}" | sed 's/\.$//' | _lower_case)
+
+ i=2
+ p=1
+ while true; do
+ candidate=$(printf "%s" "${fulldomain_lc}" | cut -d . -f "${i}"-100)
+ if [ -z "${candidate}" ]; then
+ return 1
+ fi
+
+ if _hetznercloud_get_zone_by_candidate "${candidate}"; then
+ zone_name_lc="${_hetznercloud_zone_name_lc}"
+ if [ "${fulldomain_lc}" = "${zone_name_lc}" ]; then
+ _hetznercloud_rr_name="@"
+ else
+ suffix=".${zone_name_lc}"
+ if _endswith "${fulldomain_lc}" "${suffix}"; then
+ _hetznercloud_rr_name="${fulldomain_lc%"${suffix}"}"
+ else
+ _hetznercloud_rr_name="${fulldomain_lc}"
+ fi
+ fi
+ _hetznercloud_rrset_path=$(printf "%s" "${_hetznercloud_rr_name}" | _url_encode)
+ _hetznercloud_rrset_action_add="/zones/${_hetznercloud_zone_id}/rrsets/${_hetznercloud_rrset_path}/TXT/actions/add_records"
+ _hetznercloud_rrset_action_remove="/zones/${_hetznercloud_zone_id}/rrsets/${_hetznercloud_rrset_path}/TXT/actions/remove_records"
+ return 0
+ fi
+ p=${i}
+ i=$(_math "${i}" + 1)
+ done
+}
+
+_hetznercloud_get_zone_by_candidate() {
+ candidate="${1}"
+ zone_key=$(printf "%s" "${candidate}" | sed 's/[^A-Za-z0-9]/_/g')
+ zone_conf_key="HETZNERCLOUD_ZONE_ID_for_${zone_key}"
+
+ cached_zone_id=$(_readdomainconf "${zone_conf_key}")
+ if [ -n "${cached_zone_id}" ]; then
+ if _hetznercloud_api GET "/zones/${cached_zone_id}"; then
+ if [ "${_hetznercloud_last_http_code}" = "200" ]; then
+ zone_data=$(printf "%s" "${response}" | _normalizeJson | sed 's/^{"zone"://' | sed 's/}$//')
+ if _hetznercloud_parse_zone_fields "${zone_data}"; then
+ zone_name_lc=$(printf "%s" "${_hetznercloud_zone_name}" | _lower_case)
+ if [ "${zone_name_lc}" = "${candidate}" ]; then
+ return 0
+ fi
+ fi
+ elif [ "${_hetznercloud_last_http_code}" = "404" ]; then
+ _cleardomainconf "${zone_conf_key}"
+ fi
+ else
+ return 1
+ fi
+ fi
+
+ if _hetznercloud_api GET "/zones/${candidate}"; then
+ if [ "${_hetznercloud_last_http_code}" = "200" ]; then
+ zone_data=$(printf "%s" "${response}" | _normalizeJson | sed 's/^{"zone"://' | sed 's/}$//')
+ if _hetznercloud_parse_zone_fields "${zone_data}"; then
+ zone_name_lc=$(printf "%s" "${_hetznercloud_zone_name}" | _lower_case)
+ if [ "${zone_name_lc}" = "${candidate}" ]; then
+ _savedomainconf "${zone_conf_key}" "${_hetznercloud_zone_id}"
+ return 0
+ fi
+ fi
+ elif [ "${_hetznercloud_last_http_code}" != "404" ]; then
+ _hetznercloud_log_http_error "Hetzner Cloud zone lookup failed" "${_hetznercloud_last_http_code}"
+ return 1
+ fi
+ else
+ return 1
+ fi
+
+ encoded_candidate=$(printf "%s" "${candidate}" | _url_encode)
+ if ! _hetznercloud_api GET "/zones?name=${encoded_candidate}"; then
+ return 1
+ fi
+ if [ "${_hetznercloud_last_http_code}" != "200" ]; then
+ if [ "${_hetznercloud_last_http_code}" = "404" ]; then
+ return 1
+ fi
+ _hetznercloud_log_http_error "Hetzner Cloud zone search failed" "${_hetznercloud_last_http_code}"
+ return 1
+ fi
+
+ zone_data=$(_hetznercloud_extract_zone_from_list "${response}" "${candidate}")
+ if [ -z "${zone_data}" ]; then
+ return 1
+ fi
+ if ! _hetznercloud_parse_zone_fields "${zone_data}"; then
+ return 1
+ fi
+ _savedomainconf "${zone_conf_key}" "${_hetznercloud_zone_id}"
+ return 0
+}
+
+_hetznercloud_parse_zone_fields() {
+ zone_json="${1}"
+ if [ -z "${zone_json}" ]; then
+ return 1
+ fi
+ normalized=$(printf "%s" "${zone_json}" | _normalizeJson)
+ zone_id=$(printf "%s" "${normalized}" | _egrep_o '"id":[^,}]*' | _head_n 1 | cut -d : -f 2 | tr -d ' "')
+ zone_name=$(printf "%s" "${normalized}" | _egrep_o '"name":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"')
+ if [ -z "${zone_id}" ] || [ -z "${zone_name}" ]; then
+ return 1
+ fi
+ zone_name_trimmed=$(printf "%s" "${zone_name}" | sed 's/\.$//')
+ if zone_name_ascii=$(_idn "${zone_name_trimmed}"); then
+ zone_name="${zone_name_ascii}"
+ else
+ zone_name="${zone_name_trimmed}"
+ fi
+ _hetznercloud_zone_id="${zone_id}"
+ _hetznercloud_zone_name="${zone_name}"
+ _hetznercloud_zone_name_lc=$(printf "%s" "${zone_name}" | _lower_case)
+ return 0
+}
+
+_hetznercloud_extract_zone_from_list() {
+ list_response=$(printf "%s" "${1}" | _normalizeJson)
+ candidate="${2}"
+ escaped_candidate=$(_hetznercloud_escape_regex "${candidate}")
+ printf "%s" "${list_response}" | _egrep_o "{[^{}]*\"name\":\"${escaped_candidate}\"[^{}]*}" | _head_n 1
+}
+
+_hetznercloud_escape_regex() {
+ printf "%s" "${1}" | sed 's/\\/\\\\/g' | sed 's/\./\\./g' | sed 's/-/\\-/g'
+}
+
+_hetznercloud_get_rrset() {
+ if [ -z "${_hetznercloud_zone_id}" ] || [ -z "${_hetznercloud_rrset_path}" ]; then
+ return 1
+ fi
+ if ! _hetznercloud_api GET "/zones/${_hetznercloud_zone_id}/rrsets/${_hetznercloud_rrset_path}/TXT"; then
+ return 1
+ fi
+ return 0
+}
+
+_hetznercloud_rrset_contains_value() {
+ wanted_value="${1}"
+ normalized=$(printf "%s" "${response}" | _normalizeJson)
+ escaped_value=$(_hetznercloud_escape_value "${wanted_value}")
+ search_pattern="\"value\":\"\\\\\"${escaped_value}\\\\\"\""
+ if _contains "${normalized}" "${search_pattern}"; then
+ return 0
+ fi
+ return 1
+}
+
+_hetznercloud_build_add_payload() {
+ value="${1}"
+ escaped_value=$(_hetznercloud_escape_value "${value}")
+ printf '{"ttl":%s,"records":[{"value":"\\"%s\\""}]}' "${HETZNER_TTL}" "${escaped_value}"
+}
+
+_hetznercloud_build_remove_payload() {
+ value="${1}"
+ escaped_value=$(_hetznercloud_escape_value "${value}")
+ printf '{"records":[{"value":"\\"%s\\""}]}' "${escaped_value}"
+}
+
+_hetznercloud_escape_value() {
+ printf "%s" "${1}" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g'
+}
+
+_hetznercloud_error_message() {
+ if [ -z "${response}" ]; then
+ return 1
+ fi
+ message=$(printf "%s" "${response}" | _normalizeJson | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"')
+ if [ -n "${message}" ]; then
+ printf "%s" "${message}"
+ return 0
+ fi
+ return 1
+}
+
+_hetznercloud_log_http_error() {
+ context="${1}"
+ code="${2}"
+ message="$(_hetznercloud_error_message)"
+ if [ -n "${context}" ]; then
+ if [ -n "${message}" ]; then
+ _err "${context} (HTTP ${code}): ${message}"
+ else
+ _err "${context} (HTTP ${code})"
+ fi
+ else
+ if [ -n "${message}" ]; then
+ _err "Hetzner Cloud DNS API error (HTTP ${code}): ${message}"
+ else
+ _err "Hetzner Cloud DNS API error (HTTP ${code})"
+ fi
+ fi
+}
+
+_hetznercloud_api() {
+ method="${1}"
+ ep="${2}"
+ data="${3}"
+ retried="${4}"
+
+ if [ -z "${method}" ]; then
+ method="GET"
+ fi
+
+ if ! _startswith "${ep}" "/"; then
+ ep="/${ep}"
+ fi
+ url="${HETZNER_API}${ep}"
+
+ export _H1="Authorization: Bearer ${HETZNER_TOKEN}"
+ export _H2="Accept: application/json"
+ export _H3=""
+ export _H4=""
+ export _H5=""
+
+ : >"${HTTP_HEADER}"
+
+ if [ "${method}" = "GET" ]; then
+ response="$(_get "${url}")"
+ else
+ if [ -z "${data}" ]; then
+ data="{}"
+ fi
+ response="$(_post "${data}" "${url}" "" "${method}" "application/json")"
+ fi
+ ret="${?}"
+
+ _hetznercloud_last_http_code=$(grep "^HTTP" "${HTTP_HEADER}" | _tail_n 1 | cut -d " " -f 2 | tr -d '\r\n')
+
+ if [ "${ret}" != "0" ]; then
+ return 1
+ fi
+
+ if [ "${_hetznercloud_last_http_code}" = "429" ] && [ "${retried}" != "retried" ]; then
+ retry_after=$(grep -i "^Retry-After" "${HTTP_HEADER}" | _tail_n 1 | cut -d : -f 2 | tr -d ' \r')
+ if [ -z "${retry_after}" ]; then
+ retry_after=1
+ fi
+ _info "Hetzner Cloud DNS API rate limit hit; retrying in ${retry_after} seconds."
+ _sleep "${retry_after}"
+ if ! _hetznercloud_api "${method}" "${ep}" "${data}" "retried"; then
+ return 1
+ fi
+ return 0
+ fi
+
+ return 0
+}
+
+_hetznercloud_handle_action_response() {
+ context="${1}"
+ if [ -z "${response}" ]; then
+ return 0
+ fi
+
+ normalized=$(printf "%s" "${response}" | _normalizeJson)
+
+ failed_message=""
+ if failed_message=$(_hetznercloud_extract_failed_action_message "${normalized}"); then
+ if [ -n "${failed_message}" ]; then
+ _err "Hetzner Cloud DNS ${context} failed: ${failed_message}"
+ else
+ _err "Hetzner Cloud DNS ${context} failed."
+ fi
+ return 1
+ fi
+
+ action_ids=""
+ if action_ids=$(_hetznercloud_extract_action_ids "${normalized}"); then
+ for action_id in ${action_ids}; do
+ if [ -z "${action_id}" ]; then
+ continue
+ fi
+ if ! _hetznercloud_wait_for_action "${action_id}" "${context}"; then
+ return 1
+ fi
+ done
+ fi
+
+ return 0
+}
+
+_hetznercloud_extract_failed_action_message() {
+ normalized="${1}"
+ failed_section=$(printf "%s" "${normalized}" | _egrep_o '"failed_actions":\[[^]]*\]')
+ if [ -z "${failed_section}" ]; then
+ return 1
+ fi
+ if _contains "${failed_section}" '"failed_actions":[]'; then
+ return 1
+ fi
+ message=$(printf "%s" "${failed_section}" | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"')
+ if [ -n "${message}" ]; then
+ printf "%s" "${message}"
+ else
+ printf "%s" "${failed_section}"
+ fi
+ return 0
+}
+
+_hetznercloud_extract_action_ids() {
+ normalized="${1}"
+ actions_section=$(printf "%s" "${normalized}" | _egrep_o '"actions":\[[^]]*\]')
+ if [ -z "${actions_section}" ]; then
+ return 1
+ fi
+ action_ids=$(printf "%s" "${actions_section}" | _egrep_o '"id":[0-9]*' | cut -d : -f 2 | tr -d '"' | tr '\n' ' ')
+ action_ids=$(printf "%s" "${action_ids}" | tr -s ' ')
+ action_ids=$(printf "%s" "${action_ids}" | sed 's/^ //;s/ $//')
+ if [ -z "${action_ids}" ]; then
+ return 1
+ fi
+ printf "%s" "${action_ids}"
+ return 0
+}
+
+_hetznercloud_wait_for_action() {
+ action_id="${1}"
+ context="${2}"
+ attempts="0"
+
+ while true; do
+ if ! _hetznercloud_api GET "/actions/${action_id}"; then
+ return 1
+ fi
+ if [ "${_hetznercloud_last_http_code}" != "200" ]; then
+ _hetznercloud_log_http_error "Hetzner Cloud DNS action ${action_id} query failed" "${_hetznercloud_last_http_code}"
+ return 1
+ fi
+
+ normalized=$(printf "%s" "${response}" | _normalizeJson)
+ action_status=$(_hetznercloud_action_status_from_normalized "${normalized}")
+
+ if [ -z "${action_status}" ]; then
+ _err "Hetzner Cloud DNS ${context} action ${action_id} returned no status."
+ return 1
+ fi
+
+ if [ "${action_status}" = "success" ]; then
+ return 0
+ fi
+
+ if [ "${action_status}" = "error" ]; then
+ if action_error=$(_hetznercloud_action_error_from_normalized "${normalized}"); then
+ _err "Hetzner Cloud DNS ${context} action ${action_id} failed: ${action_error}"
+ else
+ _err "Hetzner Cloud DNS ${context} action ${action_id} failed."
+ fi
+ return 1
+ fi
+
+ attempts=$(_math "${attempts}" + 1)
+ if [ "${attempts}" -ge "${HETZNER_MAX_ATTEMPTS}" ]; then
+ _err "Hetzner Cloud DNS ${context} action ${action_id} did not complete after ${HETZNER_MAX_ATTEMPTS} attempts."
+ return 1
+ fi
+
+ _sleep 1
+ done
+}
+
+_hetznercloud_action_status_from_normalized() {
+ normalized="${1}"
+ status=$(printf "%s" "${normalized}" | _egrep_o '"status":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"')
+ printf "%s" "${status}"
+}
+
+_hetznercloud_action_error_from_normalized() {
+ normalized="${1}"
+ error_section=$(printf "%s" "${normalized}" | _egrep_o '"error":{[^}]*}')
+ if [ -z "${error_section}" ]; then
+ return 1
+ fi
+ message=$(printf "%s" "${error_section}" | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"')
+ if [ -n "${message}" ]; then
+ printf "%s" "${message}"
+ return 0
+ fi
+ code=$(printf "%s" "${error_section}" | _egrep_o '"code":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"')
+ if [ -n "${code}" ]; then
+ printf "%s" "${code}"
+ return 0
+ fi
+ return 1
+}
diff --git a/dnsapi/dns_hostup.sh b/dnsapi/dns_hostup.sh
new file mode 100644
index 00000000..b3211069
--- /dev/null
+++ b/dnsapi/dns_hostup.sh
@@ -0,0 +1,501 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034,SC2154
+
+dns_hostup_info='HostUp DNS
+Site: hostup.se
+Docs: https://developer.hostup.se/
+Options:
+ HOSTUP_API_KEY Required. HostUp API key with read:dns + write:dns + read:domains scopes.
+ HOSTUP_API_BASE Optional. Override API base URL (default: https://cloud.hostup.se/api).
+ HOSTUP_TTL Optional. TTL for TXT records (default: 60 seconds).
+ HOSTUP_ZONE_ID Optional. Force a specific zone ID (skip auto-detection).
+Author: HostUp (https://cloud.hostup.se/contact/en)
+'
+
+HOSTUP_API_BASE_DEFAULT="https://cloud.hostup.se/api"
+HOSTUP_DEFAULT_TTL=60
+
+# Public: add TXT record
+# Usage: dns_hostup_add _acme-challenge.example.com "txt-value"
+dns_hostup_add() {
+ fulldomain="$1"
+ txtvalue="$2"
+
+ _info "Using HostUp DNS API"
+
+ if ! _hostup_init; then
+ return 1
+ fi
+
+ if ! _hostup_detect_zone "$fulldomain"; then
+ _err "Unable to determine HostUp zone for $fulldomain"
+ return 1
+ fi
+
+ record_name="$(_hostup_record_name "$fulldomain" "$HOSTUP_ZONE_DOMAIN")"
+ record_name="$(_hostup_sanitize_name "$record_name")"
+ record_value="$(_hostup_json_escape "$txtvalue")"
+
+ ttl="${HOSTUP_TTL:-$HOSTUP_DEFAULT_TTL}"
+
+ _debug "zone_id" "$HOSTUP_ZONE_ID"
+ _debug "zone_domain" "$HOSTUP_ZONE_DOMAIN"
+ _debug "record_name" "$record_name"
+ _debug "ttl" "$ttl"
+
+ request_body="{\"name\":\"$record_name\",\"type\":\"TXT\",\"value\":\"$record_value\",\"ttl\":$ttl}"
+
+ if ! _hostup_rest "POST" "/dns/zones/$HOSTUP_ZONE_ID/records" "$request_body"; then
+ return 1
+ fi
+
+ if ! _contains "$_hostup_response" '"success":true'; then
+ _err "HostUp DNS API: failed to create TXT record for $fulldomain"
+ _debug2 "_hostup_response" "$_hostup_response"
+ return 1
+ fi
+
+ record_id="$(_hostup_extract_record_id "$_hostup_response")"
+ if [ -n "$record_id" ]; then
+ _hostup_save_record_id "$HOSTUP_ZONE_ID" "$fulldomain" "$record_id"
+ _debug "hostup_saved_record_id" "$record_id"
+ fi
+
+ _info "Added TXT record for $fulldomain"
+ return 0
+}
+
+# Public: remove TXT record
+# Usage: dns_hostup_rm _acme-challenge.example.com "txt-value"
+dns_hostup_rm() {
+ fulldomain="$1"
+ txtvalue="$2"
+
+ _info "Using HostUp DNS API"
+
+ if ! _hostup_init; then
+ return 1
+ fi
+
+ if ! _hostup_detect_zone "$fulldomain"; then
+ _err "Unable to determine HostUp zone for $fulldomain"
+ return 1
+ fi
+
+ record_name_fqdn="$(_hostup_fqdn "$fulldomain")"
+ record_value="$txtvalue"
+
+ record_id_cached="$(_hostup_get_saved_record_id "$HOSTUP_ZONE_ID" "$fulldomain")"
+ if [ -n "$record_id_cached" ]; then
+ _debug "hostup_record_id_cached" "$record_id_cached"
+ if _hostup_delete_record_by_id "$HOSTUP_ZONE_ID" "$record_id_cached"; then
+ _info "Deleted TXT record $record_id_cached"
+ _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain"
+ HOSTUP_ZONE_ID=""
+ return 0
+ fi
+ fi
+
+ if ! _hostup_find_record "$HOSTUP_ZONE_ID" "$record_name_fqdn" "$record_value"; then
+ _info "TXT record not found for $record_name_fqdn. Skipping removal."
+ _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain"
+ return 0
+ fi
+
+ _debug "Deleting record" "$HOSTUP_RECORD_ID"
+
+ if ! _hostup_delete_record_by_id "$HOSTUP_ZONE_ID" "$HOSTUP_RECORD_ID"; then
+ return 1
+ fi
+
+ _info "Deleted TXT record $HOSTUP_RECORD_ID"
+ _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain"
+ HOSTUP_ZONE_ID=""
+ return 0
+}
+
+##########################
+# Private helper methods #
+##########################
+
+_hostup_init() {
+ HOSTUP_API_KEY="${HOSTUP_API_KEY:-$(_readaccountconf_mutable HOSTUP_API_KEY)}"
+ HOSTUP_API_BASE="${HOSTUP_API_BASE:-$(_readaccountconf_mutable HOSTUP_API_BASE)}"
+ HOSTUP_TTL="${HOSTUP_TTL:-$(_readaccountconf_mutable HOSTUP_TTL)}"
+ HOSTUP_ZONE_ID="${HOSTUP_ZONE_ID:-$(_readaccountconf_mutable HOSTUP_ZONE_ID)}"
+
+ if [ -z "$HOSTUP_API_BASE" ]; then
+ HOSTUP_API_BASE="$HOSTUP_API_BASE_DEFAULT"
+ fi
+
+ if [ -z "$HOSTUP_API_KEY" ]; then
+ HOSTUP_API_KEY=""
+ _err "HOSTUP_API_KEY is not set."
+ _err "Please export your HostUp API key with read:dns and write:dns scopes."
+ return 1
+ fi
+
+ _saveaccountconf_mutable HOSTUP_API_KEY "$HOSTUP_API_KEY"
+ _saveaccountconf_mutable HOSTUP_API_BASE "$HOSTUP_API_BASE"
+
+ if [ -n "$HOSTUP_TTL" ]; then
+ _saveaccountconf_mutable HOSTUP_TTL "$HOSTUP_TTL"
+ fi
+
+ if [ -n "$HOSTUP_ZONE_ID" ]; then
+ _saveaccountconf_mutable HOSTUP_ZONE_ID "$HOSTUP_ZONE_ID"
+ fi
+
+ return 0
+}
+
+_hostup_detect_zone() {
+ fulldomain="$1"
+
+ if [ -n "$HOSTUP_ZONE_ID" ] && [ -n "$HOSTUP_ZONE_DOMAIN" ]; then
+ return 0
+ fi
+
+ HOSTUP_ZONE_DOMAIN=""
+ _debug "hostup_full_domain" "$fulldomain"
+
+ if [ -n "$HOSTUP_ZONE_ID" ] && [ -z "$HOSTUP_ZONE_DOMAIN" ]; then
+ # Attempt to fetch domain name for provided zone ID
+ if _hostup_fetch_zone_details "$HOSTUP_ZONE_ID"; then
+ return 0
+ fi
+ HOSTUP_ZONE_ID=""
+ fi
+
+ if ! _hostup_load_zones; then
+ return 1
+ fi
+
+ _domain_candidate="$(printf "%s" "$fulldomain" | _lower_case)"
+ _debug "hostup_initial_candidate" "$_domain_candidate"
+
+ while [ -n "$_domain_candidate" ]; do
+ _debug "hostup_zone_candidate" "$_domain_candidate"
+ if _hostup_lookup_zone "$_domain_candidate"; then
+ HOSTUP_ZONE_DOMAIN="$_lookup_zone_domain"
+ HOSTUP_ZONE_ID="$_lookup_zone_id"
+ return 0
+ fi
+
+ case "$_domain_candidate" in
+ *.*) ;;
+ *) break ;;
+ esac
+
+ _domain_candidate="${_domain_candidate#*.}"
+ done
+
+ HOSTUP_ZONE_ID=""
+ return 1
+}
+
+_hostup_record_name() {
+ fulldomain="$1"
+ zonedomain="$2"
+
+ # Remove trailing dot, if any
+ fulldomain="${fulldomain%.}"
+ zonedomain="${zonedomain%.}"
+
+ if [ "$fulldomain" = "$zonedomain" ]; then
+ printf "%s" "@"
+ return 0
+ fi
+
+ suffix=".$zonedomain"
+ case "$fulldomain" in
+ *"$suffix")
+ printf "%s" "${fulldomain%"$suffix"}"
+ ;;
+ *)
+ # Domain not within zone, fall back to full host
+ printf "%s" "$fulldomain"
+ ;;
+ esac
+}
+
+_hostup_sanitize_name() {
+ name="$1"
+
+ if [ -z "$name" ] || [ "$name" = "." ]; then
+ printf "%s" "@"
+ return 0
+ fi
+
+ # Remove any trailing dot
+ name="${name%.}"
+ printf "%s" "$name"
+}
+
+_hostup_fqdn() {
+ domain="$1"
+ printf "%s" "${domain%.}"
+}
+
+_hostup_fetch_zone_details() {
+ zone_id="$1"
+
+ if ! _hostup_rest "GET" "/dns/zones/$zone_id/records" ""; then
+ return 1
+ fi
+
+ zonedomain="$(printf "%s" "$_hostup_response" | _egrep_o '"domain":"[^"]*"' | sed -n '1p' | cut -d ':' -f 2 | tr -d '"')"
+ if [ -n "$zonedomain" ]; then
+ HOSTUP_ZONE_DOMAIN="$zonedomain"
+ return 0
+ fi
+
+ return 1
+}
+
+_hostup_load_zones() {
+ if ! _hostup_rest "GET" "/dns/zones" ""; then
+ return 1
+ fi
+
+ HOSTUP_ZONES_CACHE=""
+ data="$(printf "%s" "$_hostup_response" | tr '{' '\n')"
+
+ while IFS= read -r line; do
+ case "$line" in
+ *'"domain_id"'*'"domain"'*)
+ zone_id="$(printf "%s" "$line" | _hostup_json_extract "domain_id")"
+ zone_domain="$(printf "%s" "$line" | _hostup_json_extract "domain")"
+ if [ -n "$zone_id" ] && [ -n "$zone_domain" ]; then
+ HOSTUP_ZONES_CACHE="${HOSTUP_ZONES_CACHE}${zone_domain}|${zone_id}
+"
+ _debug "hostup_zone_loaded" "$zone_domain|$zone_id"
+ fi
+ ;;
+ esac
+ done <%s
+
+ content
+
+ %s
+
+
- ' "$_domain" "$_sub_domain")
+ ' "$_domain" "$_sub_domain" "$txtvalue")
response="$(_post "$xml_content" "$INWX_Api" "" "POST")"
if ! _contains "$response" "Command completed successfully"; then
@@ -125,7 +132,7 @@ dns_inwx_rm() {
if ! printf "%s" "$response" | grep "count" >/dev/null; then
_info "Do not need to delete record"
else
- _record_id=$(printf '%s' "$response" | _egrep_o '.*(record){1}(.*)([0-9]+){1}' | _egrep_o 'id<\/name>[0-9]+' | _egrep_o '[0-9]+')
+ _record_id=$(printf '%s' "$response" | _egrep_o '.*(record){1}(.*)([0-9]+){1}' | _egrep_o 'id<\/name>[0-9]+' | _egrep_o '[0-9]+')
_info "Deleting record"
_inwx_delete_record "$_record_id"
fi
@@ -324,7 +331,7 @@ _inwx_delete_record() {
id
- %s
+ %s
@@ -362,7 +369,7 @@ _inwx_update_record() {
id
- %s
+ %s
diff --git a/dnsapi/dns_mgwm.sh b/dnsapi/dns_mgwm.sh
new file mode 100644
index 00000000..57679127
--- /dev/null
+++ b/dnsapi/dns_mgwm.sh
@@ -0,0 +1,109 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_mgwm_info='mgw-media.de
+Site: mgw-media.de
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_mgwm
+Options:
+ MGWM_CUSTOMER Your customer number
+ MGWM_API_HASH Your API Hash
+Issues: github.com/acmesh-official/acme.sh/issues/6669
+'
+# Base URL for the mgw-media.de API
+MGWM_API_BASE="https://api.mgw-media.de/record"
+
+######## Public functions #####################
+
+# This function is called by acme.sh to add a TXT record.
+dns_mgwm_add() {
+ fulldomain=$1
+ txtvalue=$2
+ _info "Using mgw-media.de DNS API for domain $fulldomain (add record)"
+ _debug "fulldomain: $fulldomain"
+ _debug "txtvalue: $txtvalue"
+
+ # Call the new private function to handle the API request.
+ # The 'add' action, fulldomain, type 'txt' and txtvalue are passed.
+ if _mgwm_request "add" "$fulldomain" "txt" "$txtvalue"; then
+ _info "TXT record for $fulldomain successfully added via mgw-media.de API."
+ _sleep 10 # Wait briefly for DNS propagation, a common practice in DNS-01 hooks.
+ return 0
+ else
+ # Error message already logged by _mgwm_request, but a specific one here helps.
+ _err "mgwm_add: Failed to add TXT record for $fulldomain."
+ return 1
+ fi
+}
+# This function is called by acme.sh to remove a TXT record after validation.
+dns_mgwm_rm() {
+ fulldomain=$1
+ txtvalue=$2 # This txtvalue is now used to identify the specific record to be removed.
+ _info "Removing TXT record for $fulldomain using mgw-media.de DNS API (remove record)"
+ _debug "fulldomain: $fulldomain"
+ _debug "txtvalue: $txtvalue"
+
+ # Call the new private function to handle the API request.
+ # The 'rm' action, fulldomain, type 'txt' and txtvalue are passed.
+ if _mgwm_request "rm" "$fulldomain" "txt" "$txtvalue"; then
+ _info "TXT record for $fulldomain successfully removed via mgw-media.de API."
+ return 0
+ else
+ # Error message already logged by _mgwm_request, but a specific one here helps.
+ _err "mgwm_rm: Failed to remove TXT record for $fulldomain."
+ return 1
+ fi
+}
+#################### Private functions below ##################################
+
+# _mgwm_request() encapsulates the API call logic, including
+# loading credentials, setting the Authorization header, and executing the request.
+# Arguments:
+# $1: action (e.g., "add", "rm")
+# $2: fulldomain
+# $3: type (e.g., "txt")
+# $4: content (the txtvalue)
+_mgwm_request() {
+ _action="$1"
+ _fulldomain="$2"
+ _type="$3"
+ _content="$4"
+
+ _debug "Calling _mgwm_request for action: $_action, domain: $_fulldomain, type: $_type, content: $_content"
+
+ # Load credentials from environment or acme.sh config
+ MGWM_CUSTOMER="${MGWM_CUSTOMER:-$(_readaccountconf_mutable MGWM_CUSTOMER)}"
+ MGWM_API_HASH="${MGWM_API_HASH:-$(_readaccountconf_mutable MGWM_API_HASH)}"
+
+ # Check if credentials are set
+ if [ -z "$MGWM_CUSTOMER" ] || [ -z "$MGWM_API_HASH" ]; then
+ _err "You didn't specify one or more of MGWM_CUSTOMER or MGWM_API_HASH."
+ _err "Please check these environment variables and try again."
+ return 1
+ fi
+
+ # Save credentials for automatic renewal and future calls
+ _saveaccountconf_mutable MGWM_CUSTOMER "$MGWM_CUSTOMER"
+ _saveaccountconf_mutable MGWM_API_HASH "$MGWM_API_HASH"
+
+ # Create the Basic Auth Header. acme.sh's _base64 function is used for encoding.
+ _credentials="$(printf "%s:%s" "$MGWM_CUSTOMER" "$MGWM_API_HASH" | _base64)"
+ export _H1="Authorization: Basic $_credentials"
+ _debug "Set Authorization Header: Basic " # Log debug message without sensitive credentials
+
+ # Construct the API URL based on the action and provided parameters.
+ _request_url="${MGWM_API_BASE}/${_action}/${_fulldomain}/${_type}/${_content}"
+ _debug "Constructed mgw-media.de API URL for action '$_action': ${_request_url}"
+
+ # Execute the HTTP GET request with the Authorization Header.
+ # The 5th parameter of _get is where acme.sh expects custom HTTP headers like Authorization.
+ response="$(_get "$_request_url")"
+ _debug "mgw-media.de API response for action '$_action': $response"
+
+ # Check the API response for success. The API returns "OK" on success.
+ if [ "$response" = "OK" ]; then
+ _info "mgw-media.de API action '$_action' for record '$_fulldomain' successful."
+ return 0
+ else
+ _err "Failed mgw-media.de API action '$_action' for record '$_fulldomain'. Unexpected API Response: '$response'"
+ return 1
+ fi
+}
diff --git a/dnsapi/dns_nanelo.sh b/dnsapi/dns_nanelo.sh
index 1ab47a89..0c42989b 100644
--- a/dnsapi/dns_nanelo.sh
+++ b/dnsapi/dns_nanelo.sh
@@ -27,8 +27,16 @@ dns_nanelo_add() {
fi
_saveaccountconf_mutable NANELO_TOKEN "$NANELO_TOKEN"
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain"
+ return 1
+ fi
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
_info "Adding TXT record to ${fulldomain}"
- response="$(_get "$NANELO_API$NANELO_TOKEN/dns/addrecord?type=TXT&ttl=60&name=${fulldomain}&value=${txtvalue}")"
+ response="$(_post "" "$NANELO_API$NANELO_TOKEN/dns/addrecord?domain=${_domain}&type=TXT&ttl=60&name=${_sub_domain}&value=${txtvalue}" "" "" "")"
if _contains "${response}" 'success'; then
return 0
fi
@@ -51,8 +59,16 @@ dns_nanelo_rm() {
fi
_saveaccountconf_mutable NANELO_TOKEN "$NANELO_TOKEN"
+ _debug "First, let's detect the root zone:"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain"
+ return 1
+ fi
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
_info "Deleting resource record $fulldomain"
- response="$(_get "$NANELO_API$NANELO_TOKEN/dns/deleterecord?type=TXT&ttl=60&name=${fulldomain}&value=${txtvalue}")"
+ response="$(_post "" "$NANELO_API$NANELO_TOKEN/dns/deleterecord?domain=${_domain}&type=TXT&ttl=60&name=${_sub_domain}&value=${txtvalue}" "" "" "")"
if _contains "${response}" 'success'; then
return 0
fi
@@ -60,3 +76,45 @@ dns_nanelo_rm() {
_err "${response}"
return 1
}
+
+#################### Private functions below ##################################
+#_acme-challenge.www.domain.com
+#returns
+# _sub_domain=_acme-challenge.www
+# _domain=domain.com
+
+_get_root() {
+ fulldomain=$1
+
+ # Fetch all zones from Nanelo
+ response="$(_get "$NANELO_API$NANELO_TOKEN/dns/getzones")" || return 1
+
+ # Extract "zones" array into space-separated list
+ zones=$(echo "$response" |
+ tr -d ' \n' |
+ sed -n 's/.*"zones":\[\([^]]*\)\].*/\1/p' |
+ tr -d '"' |
+ tr , ' ')
+ _debug zones "$zones"
+
+ bestzone=""
+ for z in $zones; do
+ case "$fulldomain" in
+ *."$z" | "$z")
+ if [ ${#z} -gt ${#bestzone} ]; then
+ bestzone=$z
+ fi
+ ;;
+ esac
+ done
+
+ if [ -z "$bestzone" ]; then
+ _err "No matching zone found for $fulldomain"
+ return 1
+ fi
+
+ _domain="$bestzone"
+ _sub_domain=$(printf "%s" "$fulldomain" | sed "s/\\.$_domain\$//")
+
+ return 0
+}
diff --git a/dnsapi/dns_omglol.sh b/dnsapi/dns_omglol.sh
index df080bcf..fd38d046 100644
--- a/dnsapi/dns_omglol.sh
+++ b/dnsapi/dns_omglol.sh
@@ -4,8 +4,8 @@ dns_omglol_info='omg.lol
Site: omg.lol
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_omglol
Options:
- OMG_ApiKey API Key. This is accessible from the bottom of the account page at https://home.omg.lol/account
- OMG_Address Address. This is your omg.lol address, without the preceding @ - you can see your list on your dashboard at https://home.omg.lol/dashboard
+ OMG_ApiKey - API Key. This is accessible from the bottom of the account page at https://home.omg.lol/account
+ OMG_Address - Address. This is your omg.lol address, without the preceding @ - you can see your list on your dashboard at https://home.omg.lol/dashboard
Issues: github.com/acmesh-official/acme.sh/issues/5299
Author: @Kholin
'
@@ -35,7 +35,7 @@ dns_omglol_add() {
_debug "omg.lol Address" "$OMG_Address"
omg_validate "$OMG_ApiKey" "$OMG_Address" "$fulldomain"
- if [ ! $? ]; then
+ if [ 1 = $? ]; then
return 1
fi
@@ -67,7 +67,7 @@ dns_omglol_rm() {
_debug "omg.lol Address" "$OMG_Address"
omg_validate "$OMG_ApiKey" "$OMG_Address" "$fulldomain"
- if [ ! $? ]; then
+ if [ 1 = $? ]; then
return 1
fi
@@ -100,18 +100,49 @@ omg_validate() {
fi
_endswith "$fulldomain" "omg.lol"
- if [ ! $? ]; then
+ if [ 1 = $? ]; then
_err "Domain name requested is not under omg.lol"
return 1
fi
_endswith "$fulldomain" "$omg_address.omg.lol"
- if [ ! $? ]; then
+ if [ 1 = $? ]; then
_err "Domain name is not a subdomain of provided omg.lol address $omg_address"
return 1
fi
- _debug "Required environment parameters are all present"
+ omg_testconnect "$omg_apikey" "$omg_address"
+ if [ 1 = $? ]; then
+ _err "Authentication to omg.lol for address $omg_address using provided API key failed"
+ return 1
+ fi
+
+ _debug "Required environment parameters are all present and validated"
+}
+
+# Validate that the address and API key are both correct and associated to each other
+omg_testconnect() {
+ omg_apikey=$1
+ omg_address=$2
+
+ _debug2 "Function" "omg_testconnect"
+ _secure_debug2 "omg.lol API key" "$omg_apikey"
+ _debug2 "omg.lol Address" "$omg_address"
+
+ authheader="$(_createAuthHeader "$omg_apikey")"
+ export _H1="$authheader"
+ endpoint="https://api.omg.lol/address/$omg_address/info"
+ _debug2 "Endpoint for validation" "$endpoint"
+
+ response=$(_get "$endpoint" "" 30)
+
+ _jsonResponseCheck "$response" "status_code" 200
+ if [ 1 = $? ]; then
+ _debug2 "Failed to query omg.lol for $omg_address with provided API key"
+ _secure_debug2 "API Key" "omg_apikey"
+ _secure_debug3 "Raw response" "$response"
+ return 1
+ fi
}
# Add (or modify) an entry for a new ACME query
diff --git a/dnsapi/dns_ovh.sh b/dnsapi/dns_ovh.sh
index 24ad0904..9f2cd23f 100755
--- a/dnsapi/dns_ovh.sh
+++ b/dnsapi/dns_ovh.sh
@@ -201,7 +201,7 @@ dns_ovh_rm() {
if ! _ovh_rest GET "domain/zone/$_domain/record/$rid"; then
return 1
fi
- if _contains "$response" "\"target\":\"$txtvalue\""; then
+ if _contains "$response" "$txtvalue"; then
_debug "Found txt id:$rid"
if ! _ovh_rest DELETE "domain/zone/$_domain/record/$rid"; then
return 1
diff --git a/dnsapi/dns_qc.sh b/dnsapi/dns_qc.sh
new file mode 100755
index 00000000..78756a35
--- /dev/null
+++ b/dnsapi/dns_qc.sh
@@ -0,0 +1,216 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_qc_info='QUIC.cloud
+Site: quic.cloud
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_qc
+Options:
+ QC_API_KEY QC API Key
+ QC_API_EMAIL Your account email
+'
+
+QC_Api="https://api.quic.cloud/v2"
+
+######## Public functions #####################
+
+#Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_qc_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ _debug "Enter dns_qc_add fulldomain: $fulldomain, txtvalue: $txtvalue"
+ QC_API_KEY="${QC_API_KEY:-$(_readaccountconf_mutable QC_API_KEY)}"
+ QC_API_EMAIL="${QC_API_EMAIL:-$(_readaccountconf_mutable QC_API_EMAIL)}"
+
+ if [ "$QC_API_KEY" ]; then
+ _saveaccountconf_mutable QC_API_KEY "$QC_API_KEY"
+ else
+ _err "You didn't specify a QUIC.cloud api key as QC_API_KEY."
+ _err "You can get yours from here https://my.quic.cloud/up/api."
+ return 1
+ fi
+
+ if ! _contains "$QC_API_EMAIL" "@"; then
+ _err "It seems that the QC_API_EMAIL=$QC_API_EMAIL is not a valid email address."
+ _err "Please check and retry."
+ return 1
+ fi
+ #save the api key and email to the account conf file.
+ _saveaccountconf_mutable QC_API_EMAIL "$QC_API_EMAIL"
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain during add"
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ _debug "Getting txt records"
+ _qc_rest GET "zones/${_domain_id}/records"
+
+ if ! echo "$response" | tr -d " " | grep \"success\":true >/dev/null; then
+ _err "Error failed response from QC GET: $response"
+ return 1
+ fi
+
+ # For wildcard cert, the main root domain and the wildcard domain have the same txt subdomain name, so
+ # we can not use updating anymore.
+ # count=$(printf "%s\n" "$response" | _egrep_o "\"count\":[^,]*" | cut -d : -f 2)
+ # _debug count "$count"
+ # if [ "$count" = "0" ]; then
+ _info "Adding txt record"
+ if _qc_rest POST "zones/$_domain_id/records" "{\"type\":\"TXT\",\"name\":\"$fulldomain\",\"content\":\"$txtvalue\",\"ttl\":1800}"; then
+ if _contains "$response" "$txtvalue"; then
+ _info "Added txt record, OK"
+ return 0
+ elif _contains "$response" "Same record already exists"; then
+ _info "txt record already exists, OK"
+ return 0
+ else
+ _err "Add txt record error: $response"
+ return 1
+ fi
+ fi
+ _err "Add txt record error: POST failed: $response"
+ return 1
+
+}
+
+#fulldomain txtvalue
+dns_qc_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ _debug "Enter dns_qc_rm fulldomain: $fulldomain, txtvalue: $txtvalue"
+ QC_API_KEY="${QC_API_KEY:-$(_readaccountconf_mutable QC_API_KEY)}"
+ QC_API_EMAIL="${QC_API_EMAIL:-$(_readaccountconf_mutable QC_API_EMAIL)}"
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain during rm"
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ _debug "Getting txt records"
+ _qc_rest GET "zones/${_domain_id}/records"
+
+ if ! echo "$response" | tr -d " " | grep \"success\":true >/dev/null; then
+ _err "Error rm GET response: $response"
+ return 1
+ fi
+
+ _debug "Pre-jq response:" "$response"
+ # Do not use jq or subsequent code
+ #response=$(echo "$response" | jq ".result[] | select(.id) | select(.content == \"$txtvalue\") | select(.type == \"TXT\")")
+ #_debug "get txt response" "$response"
+ #if [ "${response}" = "" ]; then
+ # _info "Don't need to remove txt records."
+ # return 0
+ #fi
+ #record_id=$(echo "$response" | grep \"id\" | awk -F ' ' '{print $2}' | sed 's/,$//')
+ #_debug "txt record_id" "$record_id"
+ #Instead of jq
+ array=$(echo "$response" | grep -o '\[[^]]*\]' | sed 's/^\[\(.*\)\]$/\1/')
+ if [ -z "$array" ]; then
+ _err "Expected array in QC response: $response"
+ return 1
+ fi
+ # Temporary file to hold matched content (one per line)
+ tmpfile=$(_mktemp)
+ echo "$array" | grep -o '{[^}]*}' | sed 's/^{//;s/}$//' >"$tmpfile"
+ record_id=""
+
+ while IFS= read -r obj || [ -n "$obj" ]; do
+ if echo "$obj" | grep -q '"TXT"' && echo "$obj" | grep -q '"id"' && echo "$obj" | grep -q "$txtvalue"; then
+ _debug "response includes" "$obj"
+ record_id=$(echo "$obj" | sed 's/^\"id\":\([0-9]\+\).*/\1/')
+ break
+ fi
+ done <"$tmpfile"
+
+ rm "$tmpfile"
+
+ if [ -z "$record_id" ]; then
+ _info "TXT record, or $txtvalue not found, nothing to remove"
+ return 0
+ fi
+
+ #End of jq replacement
+ if ! _qc_rest DELETE "zones/$_domain_id/records/$record_id"; then
+ _info "Delete txt record error."
+ return 1
+ fi
+
+ _info "TXT Record ID: $record_id successfully deleted"
+ return 0
+
+}
+
+#################### Private functions below ##################################
+#_acme-challenge.www.domain.com
+#returns
+# _sub_domain=_acme-challenge.www
+# _domain=domain.com
+# _domain_id=sdjkglgdfewsdfg
+_get_root() {
+ domain=$1
+ i=1
+ p=1
+
+ h=$(printf "%s" "$domain" | cut -d . -f2-)
+ _debug h "$h"
+ if [ -z "$h" ]; then
+ _err "$h ($domain) is an invalid domain"
+ return 1
+ fi
+
+ if ! _qc_rest GET "zones"; then
+ _err "qc_rest failed"
+ return 1
+ fi
+
+ if _contains "$response" "\"name\":\"$h\"" || _contains "$response" "\"name\":\"$h.\""; then
+ _domain_id=$h
+ if [ "$_domain_id" ]; then
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ _domain=$h
+ return 0
+ fi
+ _err "Empty domain_id $h"
+ return 1
+ fi
+ _err "Missing domain_id $h"
+ return 1
+}
+
+_qc_rest() {
+ m=$1
+ ep="$2"
+ data="$3"
+ _debug "$ep"
+
+ email_trimmed=$(echo "$QC_API_EMAIL" | tr -d '"')
+ token_trimmed=$(echo "$QC_API_KEY" | tr -d '"')
+
+ export _H1="Content-Type: application/json"
+ export _H2="X-Auth-Email: $email_trimmed"
+ export _H3="X-Auth-Key: $token_trimmed"
+
+ if [ "$m" != "GET" ]; then
+ _debug data "$data"
+ response="$(_post "$data" "$QC_Api/$ep" "" "$m")"
+ else
+ response="$(_get "$QC_Api/$ep")"
+ fi
+
+ if [ "$?" != "0" ]; then
+ _err "error $ep"
+ return 1
+ fi
+ _debug2 response "$response"
+ return 0
+}
diff --git a/dnsapi/dns_sotoon.sh b/dnsapi/dns_sotoon.sh
new file mode 100644
index 00000000..b94a220f
--- /dev/null
+++ b/dnsapi/dns_sotoon.sh
@@ -0,0 +1,309 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_sotoon_info='Sotoon.ir
+Site: Sotoon.ir
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_sotoon
+Options:
+ Sotoon_Token API Token
+ Sotoon_WorkspaceUUID Workspace UUID
+Issues: github.com/acmesh-official/acme.sh/issues/6656
+Author: Erfan Gholizade
+'
+
+SOTOON_API_URL="https://api.sotoon.ir/delivery/v2.1/global"
+
+######## Public functions #####################
+
+#Adding the txt record for validation.
+#Usage: dns_sotoon_add fulldomain TXT_record
+#Usage: dns_sotoon_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_sotoon_add() {
+ fulldomain=$1
+ txtvalue=$2
+ _info_sotoon "Using Sotoon"
+
+ Sotoon_Token="${Sotoon_Token:-$(_readaccountconf_mutable Sotoon_Token)}"
+ Sotoon_WorkspaceUUID="${Sotoon_WorkspaceUUID:-$(_readaccountconf_mutable Sotoon_WorkspaceUUID)}"
+
+ if [ -z "$Sotoon_Token" ]; then
+ _err_sotoon "You didn't specify \"Sotoon_Token\" token yet."
+ _err_sotoon "You can get yours from here https://ocean.sotoon.ir/profile/tokens"
+ return 1
+ fi
+ if [ -z "$Sotoon_WorkspaceUUID" ]; then
+ _err_sotoon "You didn't specify \"Sotoon_WorkspaceUUID\" Workspace UUID yet."
+ _err_sotoon "You can get yours from here https://ocean.sotoon.ir/profile/workspaces"
+ return 1
+ fi
+
+ #save the info to the account conf file.
+ _saveaccountconf_mutable Sotoon_Token "$Sotoon_Token"
+ _saveaccountconf_mutable Sotoon_WorkspaceUUID "$Sotoon_WorkspaceUUID"
+
+ _debug_sotoon "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err_sotoon "invalid domain"
+ return 1
+ fi
+
+ _info_sotoon "Adding record"
+
+ _debug_sotoon _domain_id "$_domain_id"
+ _debug_sotoon _sub_domain "$_sub_domain"
+ _debug_sotoon _domain "$_domain"
+
+ # First, GET the current domain zone to check for existing TXT records
+ # This is needed for wildcard certs which require multiple TXT values
+ _info_sotoon "Checking for existing TXT records"
+ if ! _sotoon_rest GET "$_domain_id"; then
+ _err_sotoon "Failed to get domain zone"
+ return 1
+ fi
+
+ # Check if there are existing TXT records for this subdomain
+ _existing_txt=""
+ if _contains "$response" "\"$_sub_domain\""; then
+ _debug_sotoon "Found existing records for $_sub_domain"
+ # Extract existing TXT values from the response
+ # The format is: "_acme-challenge":[{"TXT":"value1","type":"TXT","ttl":10},{"TXT":"value2",...}]
+ _existing_txt=$(echo "$response" | _egrep_o "\"$_sub_domain\":\[[^]]*\]" | sed "s/\"$_sub_domain\"://")
+ _debug_sotoon "Existing TXT records: $_existing_txt"
+ fi
+
+ # Build the new record entry
+ _new_record="{\"TXT\":\"$txtvalue\",\"type\":\"TXT\",\"ttl\":120}"
+
+ # If there are existing records, append to them; otherwise create new array
+ if [ -n "$_existing_txt" ] && [ "$_existing_txt" != "[]" ] && [ "$_existing_txt" != "null" ]; then
+ # Check if this exact TXT value already exists (avoid duplicates)
+ if _contains "$_existing_txt" "\"$txtvalue\""; then
+ _info_sotoon "TXT record already exists, skipping"
+ return 0
+ fi
+ # Remove the closing bracket and append new record
+ _combined_records="$(echo "$_existing_txt" | sed 's/]$//'),$_new_record]"
+ _debug_sotoon "Combined records: $_combined_records"
+ else
+ # No existing records, create new array
+ _combined_records="[$_new_record]"
+ fi
+
+ # Prepare the DNS record data in Kubernetes CRD format
+ _dns_record="{\"spec\":{\"records\":{\"$_sub_domain\":$_combined_records}}}"
+
+ _debug_sotoon "DNS record payload: $_dns_record"
+
+ # Use PATCH to update/add the record to the domain zone
+ _info_sotoon "Updating domain zone $_domain_id with TXT record"
+ if _sotoon_rest PATCH "$_domain_id" "$_dns_record"; then
+ if _contains "$response" "$txtvalue" || _contains "$response" "\"$_sub_domain\""; then
+ _info_sotoon "Added, OK"
+ return 0
+ else
+ _debug_sotoon "Response: $response"
+ _err_sotoon "Add txt record error."
+ return 1
+ fi
+ fi
+
+ _err_sotoon "Add txt record error."
+ return 1
+}
+
+#Remove the txt record after validation.
+#Usage: dns_sotoon_rm fulldomain TXT_record
+#Usage: dns_sotoon_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_sotoon_rm() {
+ fulldomain=$1
+ txtvalue=$2
+ _info_sotoon "Using Sotoon"
+ _debug_sotoon fulldomain "$fulldomain"
+ _debug_sotoon txtvalue "$txtvalue"
+
+ Sotoon_Token="${Sotoon_Token:-$(_readaccountconf_mutable Sotoon_Token)}"
+ Sotoon_WorkspaceUUID="${Sotoon_WorkspaceUUID:-$(_readaccountconf_mutable Sotoon_WorkspaceUUID)}"
+
+ _debug_sotoon "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err_sotoon "invalid domain"
+ return 1
+ fi
+ _debug_sotoon _domain_id "$_domain_id"
+ _debug_sotoon _sub_domain "$_sub_domain"
+ _debug_sotoon _domain "$_domain"
+
+ _info_sotoon "Removing TXT record"
+
+ # First, GET the current domain zone to check for existing TXT records
+ if ! _sotoon_rest GET "$_domain_id"; then
+ _err_sotoon "Failed to get domain zone"
+ return 1
+ fi
+
+ # Check if there are existing TXT records for this subdomain
+ _existing_txt=""
+ if _contains "$response" "\"$_sub_domain\""; then
+ _debug_sotoon "Found existing records for $_sub_domain"
+ _existing_txt=$(echo "$response" | _egrep_o "\"$_sub_domain\":\[[^]]*\]" | sed "s/\"$_sub_domain\"://")
+ _debug_sotoon "Existing TXT records: $_existing_txt"
+ fi
+
+ # If no existing records, nothing to remove
+ if [ -z "$_existing_txt" ] || [ "$_existing_txt" = "[]" ] || [ "$_existing_txt" = "null" ]; then
+ _info_sotoon "No TXT records found, nothing to remove"
+ return 0
+ fi
+
+ # Remove the specific TXT value from the array
+ # This handles the case where there are multiple TXT values (wildcard certs)
+ _remaining_records=$(echo "$_existing_txt" | sed "s/{\"TXT\":\"$txtvalue\"[^}]*},*//g" | sed 's/,]/]/g' | sed 's/\[,/[/g')
+ _debug_sotoon "Remaining records after removal: $_remaining_records"
+
+ # If no records remain, set to null to remove the subdomain entirely
+ if [ "$_remaining_records" = "[]" ] || [ -z "$_remaining_records" ]; then
+ _dns_record="{\"spec\":{\"records\":{\"$_sub_domain\":null}}}"
+ else
+ _dns_record="{\"spec\":{\"records\":{\"$_sub_domain\":$_remaining_records}}}"
+ fi
+
+ _debug_sotoon "Remove record payload: $_dns_record"
+
+ # Use PATCH to remove the record from the domain zone
+ if _sotoon_rest PATCH "$_domain_id" "$_dns_record"; then
+ _info_sotoon "Record removed, OK"
+ return 0
+ else
+ _debug_sotoon "Response: $response"
+ _err_sotoon "Error removing record"
+ return 1
+ fi
+}
+
+#################### Private functions below ##################################
+
+_get_root() {
+ domain=$1
+ i=1
+ p=1
+
+ _debug_sotoon "Getting root domain for: $domain"
+ _debug_sotoon "Sotoon WorkspaceUUID: $Sotoon_WorkspaceUUID"
+
+ while true; do
+ h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+ _debug_sotoon "Checking domain part: $h"
+
+ if [ -z "$h" ]; then
+ #not valid
+ _err_sotoon "Could not find valid domain"
+ return 1
+ fi
+
+ _debug_sotoon "Fetching domain zones from Sotoon API"
+ if ! _sotoon_rest GET ""; then
+ _err_sotoon "Failed to get domain zones from Sotoon API"
+ _err_sotoon "Please check your Sotoon_Token, Sotoon_WorkspaceUUID"
+ return 1
+ fi
+
+ _debug2_sotoon "API Response: $response"
+
+ # Check if the response contains our domain
+ # Sotoon API uses Kubernetes CRD format with spec.origin for domain matching
+ if _contains "$response" "\"origin\":\"$h\""; then
+ _debug_sotoon "Found domain by origin: $h"
+
+ # In Kubernetes CRD format, the metadata.name is the resource identifier
+ # The name can be either:
+ # 1. Same as origin
+ # 2. Origin with dots replaced by hyphens
+ # We check both patterns in the response to determine which one exists
+
+ # Convert origin to hyphenated version for checking
+ _h_hyphenated=$(echo "$h" | tr '.' '-')
+
+ # Check if the hyphenated name exists in the response
+ if _contains "$response" "\"name\":\"$_h_hyphenated\""; then
+ _domain_id="$_h_hyphenated"
+ _debug_sotoon "Found domain ID (hyphenated): $_domain_id"
+ # Check if the origin itself is used as name
+ elif _contains "$response" "\"name\":\"$h\""; then
+ _domain_id="$h"
+ _debug_sotoon "Found domain ID (same as origin): $_domain_id"
+ else
+ # Fallback: use the hyphenated version (more common)
+ _domain_id="$_h_hyphenated"
+ _debug_sotoon "Using hyphenated domain ID as fallback: $_domain_id"
+ fi
+
+ if [ -n "$_domain_id" ]; then
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ _domain=$h
+ _debug_sotoon "Domain ID (metadata.name): $_domain_id"
+ _debug_sotoon "Sub domain: $_sub_domain"
+ _debug_sotoon "Domain (origin): $_domain"
+ return 0
+ fi
+ _err_sotoon "Found domain $h but could not extract domain ID"
+ return 1
+ fi
+ p=$i
+ i=$(_math "$i" + 1)
+ done
+ return 1
+}
+
+_sotoon_rest() {
+ mtd="$1"
+ resource_id="$2"
+ data="$3"
+
+ token_trimmed=$(echo "$Sotoon_Token" | tr -d '"')
+
+ # Construct the API endpoint
+ _api_path="$SOTOON_API_URL/workspaces/$Sotoon_WorkspaceUUID/domainzones"
+
+ if [ -n "$resource_id" ]; then
+ _api_path="$_api_path/$resource_id"
+ fi
+
+ _debug_sotoon "API Path: $_api_path"
+ _debug_sotoon "Method: $mtd"
+
+ # Set authorization header - Sotoon API uses Bearer token
+ export _H1="Authorization: Bearer $token_trimmed"
+
+ if [ "$mtd" = "GET" ]; then
+ # GET request
+ _debug_sotoon "GET" "$_api_path"
+ response="$(_get "$_api_path")"
+ elif [ "$mtd" = "PATCH" ]; then
+ # PATCH Request
+ export _H2="Content-Type: application/merge-patch+json"
+ _debug_sotoon data "$data"
+ response="$(_post "$data" "$_api_path" "" "$mtd")"
+ else
+ _err_sotoon "Unknown method: $mtd"
+ return 1
+ fi
+
+ _debug2_sotoon response "$response"
+ return 0
+}
+
+#Wrappers for logging
+_info_sotoon() {
+ _info "[Sotoon]" "$@"
+}
+
+_err_sotoon() {
+ _err "[Sotoon]" "$@"
+}
+
+_debug_sotoon() {
+ _debug "[Sotoon]" "$@"
+}
+
+_debug2_sotoon() {
+ _debug2 "[Sotoon]" "$@"
+}
diff --git a/dnsapi/dns_virakcloud.sh b/dnsapi/dns_virakcloud.sh
new file mode 100755
index 00000000..7ae665d2
--- /dev/null
+++ b/dnsapi/dns_virakcloud.sh
@@ -0,0 +1,229 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_virakcloud_info='VirakCloud DNS API
+Site: VirakCloud.com
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_virakcloud
+Options:
+ VIRAKCLOUD_API_TOKEN VirakCloud API Bearer Token
+'
+
+VIRAKCLOUD_API_URL="https://public-api.virakcloud.com/dns"
+
+######## Public functions #####################
+
+#Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+#Used to add txt record
+dns_virakcloud_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ VIRAKCLOUD_API_TOKEN="${VIRAKCLOUD_API_TOKEN:-$(_readaccountconf_mutable VIRAKCLOUD_API_TOKEN)}"
+
+ if [ -z "$VIRAKCLOUD_API_TOKEN" ]; then
+ _err "You haven't configured your VirakCloud API token yet."
+ _err "Please set VIRAKCLOUD_API_TOKEN environment variable or run:"
+ _err " export VIRAKCLOUD_API_TOKEN=\"your-api-token\""
+ return 1
+ fi
+
+ _saveaccountconf_mutable VIRAKCLOUD_API_TOKEN "$VIRAKCLOUD_API_TOKEN"
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ http_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ if [ "$http_code" = "401" ]; then
+ return 1
+ fi
+ _err "Invalid domain"
+ return 1
+ fi
+
+ _debug _domain "$_domain"
+ _debug fulldomain "$fulldomain"
+
+ _info "Adding TXT record"
+
+ if _virakcloud_rest POST "domains/${_domain}/records" "{\"record\":\"${fulldomain}\",\"type\":\"TXT\",\"ttl\":3600,\"content\":\"${txtvalue}\"}"; then
+ if echo "$response" | grep -q "success" || echo "$response" | grep -q "\"data\""; then
+ _info "Added, OK"
+ return 0
+ elif echo "$response" | grep -q "already exists" || echo "$response" | grep -q "duplicate"; then
+ _info "Record already exists, OK"
+ return 0
+ else
+ _err "Add TXT record error."
+ _err "Response: $response"
+ return 1
+ fi
+ fi
+
+ _err "Add TXT record error."
+ return 1
+}
+
+#Usage: fulldomain txtvalue
+#Used to remove the txt record after validation
+dns_virakcloud_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ VIRAKCLOUD_API_TOKEN="${VIRAKCLOUD_API_TOKEN:-$(_readaccountconf_mutable VIRAKCLOUD_API_TOKEN)}"
+
+ if [ -z "$VIRAKCLOUD_API_TOKEN" ]; then
+ _err "You haven't configured your VirakCloud API token yet."
+ return 1
+ fi
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ http_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ if [ "$http_code" = "401" ]; then
+ return 1
+ fi
+ _err "Invalid domain"
+ return 1
+ fi
+
+ _debug _domain "$_domain"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ _info "Removing TXT record"
+
+ _debug "Getting list of records to find content ID"
+ if ! _virakcloud_rest GET "domains/${_domain}/records" ""; then
+ return 1
+ fi
+
+ _debug2 "Records response" "$response"
+
+ contentid=""
+ # Extract innermost objects (content objects) which look like {"id":"...","content_raw":"..."}
+ # We filter for the one containing txtvalue
+
+ target_obj=$(echo "$response" | grep -o '{[^}]*}' | grep "$txtvalue" | _head_n 1)
+
+ if [ -n "$target_obj" ]; then
+ contentid=$(echo "$target_obj" | _egrep_o '"id":"[^"]*"' | cut -d '"' -f 4)
+ fi
+
+ if [ -z "$contentid" ]; then
+ _debug "Could not find matching record ID in response"
+ _info "Record not found, may have been already removed"
+ return 0
+ fi
+
+ _debug contentid "$contentid"
+
+ if _virakcloud_rest DELETE "domains/${_domain}/records/${fulldomain}/TXT/${contentid}" ""; then
+ if echo "$response" | grep -q "success" || [ -z "$response" ]; then
+ _info "Removed, OK"
+ return 0
+ elif echo "$response" | grep -q "not found" || echo "$response" | grep -q "404"; then
+ _info "Record not found, OK"
+ return 0
+ else
+ _err "Remove TXT record error."
+ _err "Response: $response"
+ return 1
+ fi
+ fi
+
+ _err "Remove TXT record error."
+ return 1
+}
+
+#################### Private functions below ##################################
+
+#_acme-challenge.www.domain.com
+#returns
+# _domain=domain.com
+_get_root() {
+ domain=$1
+ i=1
+ p=1
+
+ # Optimization: skip _acme-challenge subdomain to avoid 422 errors
+ if echo "$domain" | grep -q "^_acme-challenge."; then
+ i=2
+ fi
+
+ while true; do
+ h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+ _debug h "$h"
+
+ if [ -z "$h" ]; then
+ return 1
+ fi
+
+ if ! _virakcloud_rest GET "domains/$h" ""; then
+ http_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ if [ "$http_code" = "401" ]; then
+ return 1
+ fi
+ p=$i
+ i=$(_math "$i" + 1)
+ continue
+ fi
+
+ if echo "$response" | grep -q "\"name\""; then
+ _domain="$h"
+ return 0
+ fi
+
+ p=$i
+ i=$(_math "$i" + 1)
+ done
+
+ return 1
+}
+
+_virakcloud_rest() {
+ m=$1
+ ep="$2"
+ data="$3"
+
+ _debug "$ep"
+
+ export _H1="Content-Type: application/json"
+ export _H2="Authorization: Bearer $VIRAKCLOUD_API_TOKEN"
+
+ if [ "$m" != "GET" ]; then
+ _debug data "$data"
+ response="$(_post "$data" "$VIRAKCLOUD_API_URL/$ep" "" "$m")"
+ else
+ response="$(_get "$VIRAKCLOUD_API_URL/$ep")"
+ fi
+
+ _ret="$?"
+
+ if [ "$_ret" != "0" ]; then
+ _err "error on $m $ep"
+ return 1
+ fi
+
+ http_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ _debug "http response code" "$http_code"
+
+ if [ "$http_code" = "401" ]; then
+ _err "VirakCloud API returned 401 Unauthorized."
+ _err "Your VIRAKCLOUD_API_TOKEN is invalid or expired."
+ _err "Please check your API token and try again."
+ return 1
+ fi
+
+ if [ "$http_code" = "403" ]; then
+ _err "VirakCloud API returned 403 Forbidden."
+ _err "Your API token does not have permission to access this resource."
+ return 1
+ fi
+
+ if [ -n "$http_code" ] && [ "$http_code" -ge 400 ]; then
+ _err "VirakCloud API error. HTTP code: $http_code"
+ _err "Response: $response"
+ return 1
+ fi
+
+ _debug2 response "$response"
+ return 0
+}
diff --git a/notify/ntfy.sh b/notify/ntfy.sh
index 21e39559..3a788a84 100644
--- a/notify/ntfy.sh
+++ b/notify/ntfy.sh
@@ -14,6 +14,13 @@ ntfy_send() {
_debug "_content" "$_content"
_debug "_statusCode" "$_statusCode"
+ _priority_default="default"
+ _priority_error="high"
+
+ _tag_success="white_check_mark"
+ _tag_error="warning"
+ _tag_info="information_source"
+
NTFY_URL="${NTFY_URL:-$(_readaccountconf_mutable NTFY_URL)}"
if [ "$NTFY_URL" ]; then
_saveaccountconf_mutable NTFY_URL "$NTFY_URL"
@@ -30,7 +37,26 @@ ntfy_send() {
export _H1="Authorization: Bearer $NTFY_TOKEN"
fi
- _data="${_subject}. $_content"
+ case "$_statusCode" in
+ 0)
+ _priority="$_priority_default"
+ _tag="$_tag_success"
+ ;;
+ 1)
+ _priority="$_priority_error"
+ _tag="$_tag_error"
+ ;;
+ 2)
+ _priority="$_priority_default"
+ _tag="$_tag_info"
+ ;;
+ esac
+
+ export _H2="Priority: $_priority"
+ export _H3="Tags: $_tag"
+ export _H4="Title: $PROJECT_NAME: $_subject"
+
+ _data="$_content"
response="$(_post "$_data" "$NTFY_URL/$NTFY_TOPIC" "" "POST" "")"
if [ "$?" = "0" ] && _contains "$response" "expires"; then
diff --git a/notify/opsgenie.sh b/notify/opsgenie.sh
new file mode 100644
index 00000000..d352a18c
--- /dev/null
+++ b/notify/opsgenie.sh
@@ -0,0 +1,130 @@
+#!/usr/bin/env sh
+
+#Support OpsGenie API integration
+
+#OPSGENIE_API_KEY="" Required, opsgenie api key
+#OPSGENIE_REGION="" Optional, opsgenie region, can be EU or US (default: US)
+#OPSGENIE_PRIORITY_SUCCESS="" Optional, opsgenie priority for success (default: P5)
+#OPSGENIE_PRIORITY_ERROR="" Optional, opsgenie priority for error (default: P2)
+#OPSGENIE_PRIORITY_SKIP="" Optional, opsgenie priority for renew skipped (default: P5)
+
+_OPSGENIE_AVAIL_REGION="US,EU"
+_OPSGENIE_AVAIL_PRIORITIES="P1,P2,P3,P4,P5"
+
+opsgenie_send() {
+ _subject="$1"
+ _content="$2"
+ _status_code="$3" #0: success, 1: error, 2($RENEW_SKIP): skipped
+
+ OPSGENIE_API_KEY="${OPSGENIE_API_KEY:-$(_readaccountconf_mutable OPSGENIE_API_KEY)}"
+ if [ -z "$OPSGENIE_API_KEY" ]; then
+ OPSGENIE_API_KEY=""
+ _err "You didn't specify an OpsGenie API key OPSGENIE_API_KEY yet."
+ return 1
+ fi
+ _saveaccountconf_mutable OPSGENIE_API_KEY "$OPSGENIE_API_KEY"
+ export _H1="Authorization: GenieKey $OPSGENIE_API_KEY"
+
+ OPSGENIE_REGION="${OPSGENIE_REGION:-$(_readaccountconf_mutable OPSGENIE_REGION)}"
+ if [ -z "$OPSGENIE_REGION" ]; then
+ OPSGENIE_REGION="US"
+ _info "The OPSGENIE_REGION is not set, so use the default US as regeion."
+ elif ! _hasfield "$_OPSGENIE_AVAIL_REGION" "$OPSGENIE_REGION"; then
+ _err "The OPSGENIE_REGION \"$OPSGENIE_REGION\" is not available, should be one of $_OPSGENIE_AVAIL_REGION"
+ OPSGENIE_REGION=""
+ return 1
+ else
+ _saveaccountconf_mutable OPSGENIE_REGION "$OPSGENIE_REGION"
+ fi
+
+ OPSGENIE_PRIORITY_SUCCESS="${OPSGENIE_PRIORITY_SUCCESS:-$(_readaccountconf_mutable OPSGENIE_PRIORITY_SUCCESS)}"
+ if [ -z "$OPSGENIE_PRIORITY_SUCCESS" ]; then
+ OPSGENIE_PRIORITY_SUCCESS="P5"
+ _info "The OPSGENIE_PRIORITY_SUCCESS is not set, so use the default P5 as priority."
+ elif ! _hasfield "$_OPSGENIE_AVAIL_PRIORITIES" "$OPSGENIE_PRIORITY_SUCCESS"; then
+ _err "The OPSGENIE_PRIORITY_SUCCESS \"$OPSGENIE_PRIORITY_SUCCESS\" is not available, should be one of $_OPSGENIE_AVAIL_PRIORITIES"
+ OPSGENIE_PRIORITY_SUCCESS=""
+ return 1
+ else
+ _saveaccountconf_mutable OPSGENIE_PRIORITY_SUCCESS "$OPSGENIE_PRIORITY_SUCCESS"
+ fi
+
+ OPSGENIE_PRIORITY_ERROR="${OPSGENIE_PRIORITY_ERROR:-$(_readaccountconf_mutable OPSGENIE_PRIORITY_ERROR)}"
+ if [ -z "$OPSGENIE_PRIORITY_ERROR" ]; then
+ OPSGENIE_PRIORITY_ERROR="P2"
+ _info "The OPSGENIE_PRIORITY_ERROR is not set, so use the default P2 as priority."
+ elif ! _hasfield "$_OPSGENIE_AVAIL_PRIORITIES" "$OPSGENIE_PRIORITY_ERROR"; then
+ _err "The OPSGENIE_PRIORITY_ERROR \"$OPSGENIE_PRIORITY_ERROR\" is not available, should be one of $_OPSGENIE_AVAIL_PRIORITIES"
+ OPSGENIE_PRIORITY_ERROR=""
+ return 1
+ else
+ _saveaccountconf_mutable OPSGENIE_PRIORITY_ERROR "$OPSGENIE_PRIORITY_ERROR"
+ fi
+
+ OPSGENIE_PRIORITY_SKIP="${OPSGENIE_PRIORITY_SKIP:-$(_readaccountconf_mutable OPSGENIE_PRIORITY_SKIP)}"
+ if [ -z "$OPSGENIE_PRIORITY_SKIP" ]; then
+ OPSGENIE_PRIORITY_SKIP="P5"
+ _info "The OPSGENIE_PRIORITY_SKIP is not set, so use the default P5 as priority."
+ elif ! _hasfield "$_OPSGENIE_AVAIL_PRIORITIES" "$OPSGENIE_PRIORITY_SKIP"; then
+ _err "The OPSGENIE_PRIORITY_SKIP \"$OPSGENIE_PRIORITY_SKIP\" is not available, should be one of $_OPSGENIE_AVAIL_PRIORITIES"
+ OPSGENIE_PRIORITY_SKIP=""
+ return 1
+ else
+ _saveaccountconf_mutable OPSGENIE_PRIORITY_SKIP "$OPSGENIE_PRIORITY_SKIP"
+ fi
+
+ case "$OPSGENIE_REGION" in
+ "US")
+ _opsgenie_url="https://api.opsgenie.com/v2/alerts"
+ ;;
+ "EU")
+ _opsgenie_url="https://api.eu.opsgenie.com/v2/alerts"
+ ;;
+ *)
+ _err "opsgenie region error."
+ return 1
+ ;;
+ esac
+
+ case $_status_code in
+ 0)
+ _priority=$OPSGENIE_PRIORITY_SUCCESS
+ ;;
+ 1)
+ _priority=$OPSGENIE_PRIORITY_ERROR
+ ;;
+ 2)
+ _priority=$OPSGENIE_PRIORITY_SKIP
+ ;;
+ *)
+ _priority=$OPSGENIE_PRIORITY_ERROR
+ ;;
+ esac
+
+ _subject_json=$(echo "$_subject" | _json_encode)
+ _content_json=$(echo "$_content" | _json_encode)
+ _subject_underscore=$(echo "$_subject" | sed 's/ /_/g')
+ _alias_json=$(echo "acme.sh-$(hostname)-$_subject_underscore-$(date +%Y%m%d)" | base64 --wrap=0 | _json_encode)
+
+ _data="{
+ \"message\": \"$_subject_json\",
+ \"alias\": \"$_alias_json\",
+ \"description\": \"$_content_json\",
+ \"tags\": [
+ \"acme.sh\",
+ \"host:$(hostname)\"
+ ],
+ \"entity\": \"$(hostname -f)\",
+ \"priority\": \"$_priority\"
+}"
+
+ if response=$(_post "$_data" "$_opsgenie_url" "" "" "application/json"); then
+ if ! _contains "$response" error; then
+ _info "opsgenie send success."
+ return 0
+ fi
+ fi
+ _err "opsgenie send error."
+ _err "$response"
+ return 1
+}
diff --git a/notify/pushover.sh b/notify/pushover.sh
index 0f99739a..c59ec026 100644
--- a/notify/pushover.sh
+++ b/notify/pushover.sh
@@ -46,8 +46,8 @@ pushover_send() {
fi
export _H1="Content-Type: application/json"
- _content="$(printf "*%s*\n" "$_content" | _json_encode)"
- _subject="$(printf "*%s*\n" "$_subject" | _json_encode)"
+ _content="$(printf "%s" "$_content" | _json_encode)"
+ _subject="$(printf "%s" "$_subject" | _json_encode)"
_data="{\"token\": \"$PUSHOVER_TOKEN\",\"user\": \"$PUSHOVER_USER\",\"title\": \"$_subject\",\"message\": \"$_content\",\"sound\": \"$PUSHOVER_SOUND\", \"device\": \"$PUSHOVER_DEVICE\", \"priority\": \"$PUSHOVER_PRIORITY\"}"
response="$(_post "$_data" "$PUSHOVER_URI")"
diff --git a/notify/telegram.sh b/notify/telegram.sh
index 7da05729..4ed50a65 100644
--- a/notify/telegram.sh
+++ b/notify/telegram.sh
@@ -34,8 +34,8 @@ telegram_send() {
fi
_saveaccountconf_mutable TELEGRAM_BOT_URLBASE "$TELEGRAM_BOT_URLBASE"
- _subject="$(printf "%s" "$_subject" | sed 's/\\/\\\\\\\\/g' | sed 's/\]/\\\\\]/g' | sed 's/\([-_*[()~`>#+\-=|{}.!]\)/\\\\\1/g')"
- _content="$(printf "%s" "$_content" | sed 's/\\/\\\\\\\\/g' | sed 's/\]/\\\\\]/g' | sed 's/\([-_*[()~`>#+\-=|{}.!]\)/\\\\\1/g')"
+ _subject="$(printf "%s" "$_subject" | sed -E 's/([][()~`>#+=|{}.!*_\\-])/\\\\\1/g')"
+ _content="$(printf "%s" "$_content" | sed -E 's/([][()~`>#+=|{}.!*_\\-])/\\\\\1/g')"
_content="$(printf "*%s*\n%s" "$_subject" "$_content" | _json_encode)"
_data="{\"text\": \"$_content\", "
_data="$_data\"chat_id\": \"$TELEGRAM_BOT_CHATID\", "