diff --git a/.github/workflows/wiki-monitor.yml b/.github/workflows/wiki-monitor.yml
index b0332775..a79d70a4 100644
--- a/.github/workflows/wiki-monitor.yml
+++ b/.github/workflows/wiki-monitor.yml
@@ -22,6 +22,7 @@ jobs:
page_sha=$(jq -r '.pages[0].sha' "$GITHUB_EVENT_PATH")
page_url=$(jq -r '.pages[0].html_url' "$GITHUB_EVENT_PATH")
page_action=$(jq -r '.pages[0].action' "$GITHUB_EVENT_PATH")
+ page_summary=$(jq -r '.pages[0].summary' "$GITHUB_EVENT_PATH")
now="$(date '+%Y-%m-%d %H:%M:%S')"
cd wiki
@@ -35,9 +36,11 @@ jobs:
{
echo "Wiki edited"
echo -n "User: "
- echo "[$actor]($sender_url)"
+ echo "@$actor [$actor]($sender_url)"
echo "Time: $now"
echo "Page: [$page_name]($page_url) (Action: $page_action)"
+ echo "Comment: $page_summary"
+ echo "[Click here to Revert](${page_url}/_history)"
echo ""
echo "----"
echo "### diff:"
diff --git a/Dockerfile b/Dockerfile
index d8f8b265..88edc4a2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,6 +15,8 @@ RUN apk --no-cache add -f \
jq \
cronie
+ENV LE_WORKING_DIR=/acmebin
+
ENV LE_CONFIG_HOME=/acme.sh
ARG AUTO_UPGRADE=1
@@ -30,7 +32,7 @@ COPY ./notify /install_acme.sh/notify
RUN cd /install_acme.sh && ([ -f /install_acme.sh/acme.sh ] && /install_acme.sh/acme.sh --install || curl https://get.acme.sh | sh) && rm -rf /install_acme.sh/
-RUN ln -s /root/.acme.sh/acme.sh /usr/local/bin/acme.sh && crontab -l | grep acme.sh | sed 's#> /dev/null#> /proc/1/fd/1 2>/proc/1/fd/2#' | crontab -
+RUN ln -s $LE_WORKING_DIR/acme.sh /usr/local/bin/acme.sh && crontab -l | grep acme.sh | sed 's#> /dev/null#> /proc/1/fd/1 2>/proc/1/fd/2#' | crontab -
RUN for verb in help \
version \
@@ -64,7 +66,7 @@ RUN for verb in help \
set-default-ca \
set-default-chain \
; do \
- printf -- "%b" "#!/usr/bin/env sh\n/root/.acme.sh/acme.sh --${verb} --config-home /acme.sh \"\$@\"" >/usr/local/bin/--${verb} && chmod +x /usr/local/bin/--${verb} \
+ printf -- "%b" "#!/usr/bin/env sh\n$LE_WORKING_DIR/acme.sh --${verb} --config-home $LE_CONFIG_HOME \"\$@\"" >/usr/local/bin/--${verb} && chmod +x /usr/local/bin/--${verb} \
; done
RUN printf "%b" '#!'"/usr/bin/env sh\n \
@@ -72,7 +74,7 @@ if [ \"\$1\" = \"daemon\" ]; then \n \
exec crond -n -s -m off \n \
else \n \
exec -- \"\$@\"\n \
-fi\n" >/entry.sh && chmod +x /entry.sh
+fi\n" >/entry.sh && chmod +x /entry.sh && chmod -R o+rwx $LE_WORKING_DIR && chmod -R o+rwx $LE_CONFIG_HOME
VOLUME /acme.sh
diff --git a/README.md b/README.md
index f7038f59..4afd90a8 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
+[](https://zerossl.com/?fromacme.sh)
+
# An ACME Shell script: acme.sh
[](https://github.com/acmesh-official/acme.sh/actions/workflows/FreeBSD.yml)
@@ -84,7 +86,6 @@ Twitter: [@neilpangxa](https://twitter.com/neilpangxa)
|18|[](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|Oracle Linux
|19|[](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|Mageia
|10|[](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|Gentoo Linux
-|11|[](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|ClearLinux
|22|-----| Cloud Linux https://github.com/acmesh-official/acme.sh/issues/111
|23|-----| OpenWRT: Tested and working. See [wiki page](https://github.com/acmesh-official/acme.sh/wiki/How-to-run-on-OpenWRT)
|24|[](https://github.com/acmesh-official/letest#here-are-the-latest-status)| Proxmox: See Proxmox VE Wiki. Version [4.x, 5.0, 5.1](https://pve.proxmox.com/wiki/HTTPS_Certificate_Configuration_(Version_4.x,_5.0_and_5.1)#Let.27s_Encrypt_using_acme.sh), version [5.2 and up](https://pve.proxmox.com/wiki/Certificate_Management)
@@ -207,6 +208,8 @@ The certs will be placed in `~/.acme.sh/example.com/`
The certs will be renewed automatically every **60** days.
+The certs will default to ECC certificates.
+
More examples: https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert
@@ -358,36 +361,33 @@ Ok, it's done.
**Please use dns api mode instead.**
-# 10. Issue ECC certificates
+# 10. Issue certificates of different key types and lengths (ECC or RSA)
+
+Just set the `keylength` to a valid, supported, value.
-Just set the `keylength` parameter with a prefix `ec-`.
+Valid values for the `keylength` parameter are:
+
+1. **ec-256 (prime256v1, "ECDSA P-256", which is the default key type)**
+2. **ec-384 (secp384r1, "ECDSA P-384")**
+3. **ec-521 (secp521r1, "ECDSA P-521", which is not supported by Let's Encrypt yet.)**
+4. **2048 (RSA2048)**
+5. **3072 (RSA3072)**
+6. **4096 (RSA4096)**
For example:
-### Single domain ECC certificate
+### Single domain with ECDSA P-384 certificate
```bash
-acme.sh --issue -w /home/wwwroot/example.com -d example.com --keylength ec-256
+acme.sh --issue -w /home/wwwroot/example.com -d example.com --keylength ec-384
```
-### SAN multi domain ECC certificate
+### SAN multi domain with RSA4096 certificate
```bash
-acme.sh --issue -w /home/wwwroot/example.com -d example.com -d www.example.com --keylength ec-256
+acme.sh --issue -w /home/wwwroot/example.com -d example.com -d www.example.com --keylength 4096
```
-Please look at the `keylength` parameter above.
-
-Valid values are:
-
-1. **ec-256 (prime256v1, "ECDSA P-256", which is the default key type)**
-2. **ec-384 (secp384r1, "ECDSA P-384")**
-3. **ec-521 (secp521r1, "ECDSA P-521", which is not supported by Let's Encrypt yet.)**
-4. **2048 (RSA2048)**
-5. **3072 (RSA3072)**
-6. **4096 (RSA4096)**
-
-
# 11. Issue Wildcard certificates
It's simple, just give a wildcard domain as the `-d` parameter.
@@ -523,3 +523,20 @@ Your donation makes **acme.sh** better:
1. PayPal/Alipay(支付宝)/Wechat(微信): [https://donate.acme.sh/](https://donate.acme.sh/)
[Donate List](https://github.com/acmesh-official/acme.sh/wiki/Donate-list)
+
+# 21. About this repository
+
+> [!NOTE]
+> This repository is officially maintained by ZeroSSL as part of our commitment to providing secure and reliable SSL/TLS solutions. We welcome contributions and feedback from the community!
+> For more information about our services, including free and paid SSL/TLS certificates, visit https://zerossl.com.
+>
+> All donations made through this repository go directly to the original independent maintainer (Neil Pang), not to ZeroSSL.
+
+
+
+
+
+
+
+
+
diff --git a/acme.sh b/acme.sh
index d5d806b1..51297f52 100755
--- a/acme.sh
+++ b/acme.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env sh
-VER=3.1.2
+VER=3.1.3
PROJECT_NAME="acme.sh"
@@ -1250,7 +1250,7 @@ _idn() {
fi
}
-#_createcsr cn san_list keyfile csrfile conf acmeValidationv1
+#_createcsr cn san_list keyfile csrfile conf acmeValidationv1 extendedUsage
_createcsr() {
_debug _createcsr
domain="$1"
@@ -1259,6 +1259,7 @@ _createcsr() {
csr="$4"
csrconf="$5"
acmeValidationv1="$6"
+ extusage="$7"
_debug2 domain "$domain"
_debug2 domainlist "$domainlist"
_debug2 csrkey "$csrkey"
@@ -1267,9 +1268,8 @@ _createcsr() {
printf "[ req_distinguished_name ]\n[ req ]\ndistinguished_name = req_distinguished_name\nreq_extensions = v3_req\n[ v3_req ]" >"$csrconf"
- if [ "$Le_ExtKeyUse" ]; then
- _savedomainconf Le_ExtKeyUse "$Le_ExtKeyUse"
- printf "\nextendedKeyUsage=$Le_ExtKeyUse\n" >>"$csrconf"
+ if [ "$extusage" ]; then
+ printf "\nextendedKeyUsage=$extusage\n" >>"$csrconf"
else
printf "\nextendedKeyUsage=serverAuth,clientAuth\n" >>"$csrconf"
fi
@@ -4511,6 +4511,7 @@ issue() {
_valid_from="${16}"
_valid_to="${17}"
_certificate_profile="${18}"
+ _extended_key_usage="${19}"
if [ -z "$_ACME_IS_RENEW" ]; then
_initpath "$_main_domain" "$_key_length"
@@ -4655,12 +4656,25 @@ issue() {
return 1
fi
fi
- if ! _createcsr "$_main_domain" "$_alt_domains" "$CERT_KEY_PATH" "$CSR_PATH" "$DOMAIN_SSL_CONF"; then
+ _keyusage="$_extended_key_usage"
+ if [ "$Le_API" = "$CA_GOOGLE" ] || [ "$Le_API" = "$CA_GOOGLE_TEST" ]; then
+ if [ -z "$_keyusage" ]; then
+ #https://github.com/acmesh-official/acme.sh/issues/6610
+ #google accepts serverauth only
+ _keyusage="serverAuth"
+ fi
+ fi
+ if ! _createcsr "$_main_domain" "$_alt_domains" "$CERT_KEY_PATH" "$CSR_PATH" "$DOMAIN_SSL_CONF" "" "$_keyusage"; then
_err "Error creating CSR."
_clearup
_on_issue_err "$_post_hook"
return 1
fi
+ if [ "$_extended_key_usage" ]; then
+ _savedomainconf "Le_ExtKeyUse" "$_extended_key_usage"
+ else
+ _cleardomainconf "Le_ExtKeyUse"
+ fi
fi
_savedomainconf "Le_Keylength" "$_key_length"
@@ -5298,6 +5312,16 @@ $_authorizations_map"
return 1
fi
break
+ elif _contains "$response" "\"ready\""; then
+ _info "Order status is 'ready', let's sleep and retry."
+ _retryafter=$(echo "$responseHeaders" | grep -i "^Retry-After *:" | cut -d : -f 2 | tr -d ' ' | tr -d '\r')
+ _debug "_retryafter" "$_retryafter"
+ if [ "$_retryafter" ]; then
+ _info "Sleeping for $_retryafter seconds then retrying"
+ _sleep $_retryafter
+ else
+ _sleep 2
+ fi
elif _contains "$response" "\"processing\""; then
_info "Order status is 'processing', let's sleep and retry."
_retryafter=$(echo "$responseHeaders" | grep -i "^Retry-After *:" | cut -d : -f 2 | tr -d ' ' | tr -d '\r')
@@ -5650,7 +5674,7 @@ renew() {
_cleardomainconf Le_OCSP_Staple
fi
fi
- issue "$Le_Webroot" "$Le_Domain" "$Le_Alt" "$Le_Keylength" "$Le_RealCertPath" "$Le_RealKeyPath" "$Le_RealCACertPath" "$Le_ReloadCmd" "$Le_RealFullChainPath" "$Le_PreHook" "$Le_PostHook" "$Le_RenewHook" "$Le_LocalAddress" "$Le_ChallengeAlias" "$Le_Preferred_Chain" "$Le_Valid_From" "$Le_Valid_To" "$Le_Certificate_Profile"
+ issue "$Le_Webroot" "$Le_Domain" "$Le_Alt" "$Le_Keylength" "$Le_RealCertPath" "$Le_RealKeyPath" "$Le_RealCACertPath" "$Le_ReloadCmd" "$Le_RealFullChainPath" "$Le_PreHook" "$Le_PostHook" "$Le_RenewHook" "$Le_LocalAddress" "$Le_ChallengeAlias" "$Le_Preferred_Chain" "$Le_Valid_From" "$Le_Valid_To" "$Le_Certificate_Profile" "$Le_ExtKeyUse"
res="$?"
if [ "$res" != "0" ]; then
return "$res"
@@ -7570,6 +7594,7 @@ _process() {
_valid_from=""
_valid_to=""
_certificate_profile=""
+ _extended_key_usage=""
while [ ${#} -gt 0 ]; do
case "${1}" in
@@ -7974,7 +7999,7 @@ _process() {
shift
;;
--extended-key-usage)
- Le_ExtKeyUse="$2"
+ _extended_key_usage="$2"
shift
;;
--ocsp-must-staple | --ocsp)
@@ -8191,7 +8216,7 @@ _process() {
uninstall) uninstall "$_nocron" ;;
upgrade) upgrade ;;
issue)
- issue "$_webroot" "$_domain" "$_altdomains" "$_keylength" "$_cert_file" "$_key_file" "$_ca_file" "$_reloadcmd" "$_fullchain_file" "$_pre_hook" "$_post_hook" "$_renew_hook" "$_local_address" "$_challenge_alias" "$_preferred_chain" "$_valid_from" "$_valid_to" "$_certificate_profile"
+ issue "$_webroot" "$_domain" "$_altdomains" "$_keylength" "$_cert_file" "$_key_file" "$_ca_file" "$_reloadcmd" "$_fullchain_file" "$_pre_hook" "$_post_hook" "$_renew_hook" "$_local_address" "$_challenge_alias" "$_preferred_chain" "$_valid_from" "$_valid_to" "$_certificate_profile" "$_extended_key_usage"
;;
deploy)
deploy "$_domain" "$_deploy_hook" "$_ecc"
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/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_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_infoblox_uddi.sh b/dnsapi/dns_infoblox_uddi.sh
new file mode 100644
index 00000000..4b15088a
--- /dev/null
+++ b/dnsapi/dns_infoblox_uddi.sh
@@ -0,0 +1,244 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_infoblox_uddi_info='Infoblox UDDI
+Site: Infoblox.com
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_infoblox_uddi
+Options:
+ Infoblox_UDDI_Key API Key for Infoblox UDDI
+ Infoblox_Portal URL, e.g. "csp.infoblox.com" or "csp.eu.infoblox.com"
+Issues: github.com/acmesh-official/acme.sh/issues
+Author: Stefan Riegel
+'
+
+Infoblox_UDDI_Api="https://"
+
+######## Public functions #####################
+
+#Usage: dns_infoblox_uddi_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_infoblox_uddi_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ Infoblox_UDDI_Key="${Infoblox_UDDI_Key:-$(_readaccountconf_mutable Infoblox_UDDI_Key)}"
+ Infoblox_Portal="${Infoblox_Portal:-$(_readaccountconf_mutable Infoblox_Portal)}"
+
+ _info "Using Infoblox UDDI API"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ if [ -z "$Infoblox_UDDI_Key" ] || [ -z "$Infoblox_Portal" ]; then
+ Infoblox_UDDI_Key=""
+ Infoblox_Portal=""
+ _err "You didn't specify the Infoblox UDDI key or server (Infoblox_UDDI_Key; Infoblox_Portal)."
+ _err "Please set them via EXPORT Infoblox_UDDI_Key=your_key, EXPORT Infoblox_Portal=csp.infoblox.com and try again."
+ return 1
+ fi
+
+ _saveaccountconf_mutable Infoblox_UDDI_Key "$Infoblox_UDDI_Key"
+ _saveaccountconf_mutable Infoblox_Portal "$Infoblox_Portal"
+
+ export _H1="Authorization: Token $Infoblox_UDDI_Key"
+ export _H2="Content-Type: application/json"
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain"
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ _debug "Getting existing txt records"
+ _infoblox_rest GET "dns/record?_filter=type%20eq%20'TXT'%20and%20name_in_zone%20eq%20'$_sub_domain'%20and%20zone%20eq%20'$_domain_id'"
+
+ _info "Adding record"
+ body="{\"type\":\"TXT\",\"name_in_zone\":\"$_sub_domain\",\"zone\":\"$_domain_id\",\"ttl\":120,\"inheritance_sources\":{\"ttl\":{\"action\":\"override\"}},\"rdata\":{\"text\":\"$txtvalue\"}}"
+
+ if _infoblox_rest POST "dns/record" "$body"; then
+ if _contains "$response" "$txtvalue"; then
+ _info "Added, OK"
+ return 0
+ elif _contains "$response" '"error"'; then
+ # Check if record already exists
+ if _contains "$response" "already exists" || _contains "$response" "duplicate"; then
+ _info "Already exists, OK"
+ return 0
+ else
+ _err "Add txt record error."
+ _err "Response: $response"
+ return 1
+ fi
+ else
+ _info "Added, OK"
+ return 0
+ fi
+ fi
+ _err "Add txt record error."
+ return 1
+}
+
+#Usage: dns_infoblox_uddi_rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_infoblox_uddi_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ Infoblox_UDDI_Key="${Infoblox_UDDI_Key:-$(_readaccountconf_mutable Infoblox_UDDI_Key)}"
+ Infoblox_Portal="${Infoblox_Portal:-$(_readaccountconf_mutable Infoblox_Portal)}"
+
+ if [ -z "$Infoblox_UDDI_Key" ] || [ -z "$Infoblox_Portal" ]; then
+ _err "Credentials not found"
+ return 1
+ fi
+
+ _info "Using Infoblox UDDI API"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ export _H1="Authorization: Token $Infoblox_UDDI_Key"
+ export _H2="Content-Type: application/json"
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain"
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ _debug "Getting txt records to delete"
+ # Filter by txtvalue to support wildcard certs (multiple TXT records)
+ filter="type%20eq%20'TXT'%20and%20name_in_zone%20eq%20'$_sub_domain'%20and%20zone%20eq%20'$_domain_id'%20and%20rdata.text%20eq%20'$txtvalue'"
+ _infoblox_rest GET "dns/record?_filter=$filter"
+
+ if ! _contains "$response" '"results"'; then
+ _info "Don't need to remove, record not found."
+ return 0
+ fi
+
+ record_id=$(echo "$response" | _egrep_o '"id":[[:space:]]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
+ _debug "record_id" "$record_id"
+
+ if [ -z "$record_id" ]; then
+ _info "Don't need to remove, record not found."
+ return 0
+ fi
+
+ # Extract UUID from the full record ID (format: dns/record/uuid)
+ record_uuid=$(echo "$record_id" | sed 's|.*/||')
+ _debug "record_uuid" "$record_uuid"
+
+ if ! _infoblox_rest DELETE "dns/record/$record_uuid"; then
+ _err "Delete record error."
+ return 1
+ fi
+
+ _info "Removed record successfully"
+ return 0
+}
+
+#################### Private functions below ##################################
+
+#_acme-challenge.www.domain.com
+#returns
+# _sub_domain=_acme-challenge.www
+# _domain=domain.com
+# _domain_id=dns/auth_zone/xxxx-xxxx
+_get_root() {
+ domain=$1
+ i=1
+ p=1
+
+ # Remove _acme-challenge prefix if present
+ domain_no_acme=$(echo "$domain" | sed 's/^_acme-challenge\.//')
+
+ while true; do
+ h=$(printf "%s" "$domain_no_acme" | cut -d . -f "$i"-100)
+ _debug h "$h"
+ if [ -z "$h" ]; then
+ # not valid
+ return 1
+ fi
+
+ # Query for the zone with both trailing dot and without
+ filter="fqdn%20eq%20'$h.'%20or%20fqdn%20eq%20'$h'"
+ if ! _infoblox_rest GET "dns/auth_zone?_filter=$filter"; then
+ # API error - don't continue if we get auth errors
+ if _contains "$response" "401" || _contains "$response" "Authorization"; then
+ _err "Authentication failed. Please check your Infoblox_UDDI_Key."
+ return 1
+ fi
+ # For other errors, continue to parent domain
+ p=$i
+ i=$((i + 1))
+ continue
+ fi
+
+ # Check if response contains results (even if empty)
+ if _contains "$response" '"results"'; then
+ # Extract zone ID - must match the pattern dns/auth_zone/...
+ zone_id=$(echo "$response" | _egrep_o '"id":[[:space:]]*"dns/auth_zone/[^"]*"' | _head_n 1 | cut -d '"' -f 4)
+ if [ -n "$zone_id" ]; then
+ # Found the zone
+ _domain="$h"
+ _domain_id="$zone_id"
+
+ # Calculate subdomain
+ if [ "$_domain" = "$domain" ]; then
+ _sub_domain=""
+ else
+ _cutlength=$((${#domain} - ${#_domain} - 1))
+ _sub_domain=$(printf "%s" "$domain" | cut -c "1-$_cutlength")
+ fi
+
+ return 0
+ fi
+ fi
+
+ p=$i
+ i=$((i + 1))
+ done
+
+ return 1
+}
+
+# _infoblox_rest GET "dns/record?_filter=..."
+# _infoblox_rest POST "dns/record" "{json body}"
+# _infoblox_rest DELETE "dns/record/uuid"
+_infoblox_rest() {
+ method=$1
+ ep="$2"
+ data="$3"
+
+ _debug "$ep"
+
+ # Ensure credentials are available (when called from _get_root)
+ Infoblox_UDDI_Key="${Infoblox_UDDI_Key:-$(_readaccountconf_mutable Infoblox_UDDI_Key)}"
+ Infoblox_Portal="${Infoblox_Portal:-$(_readaccountconf_mutable Infoblox_Portal)}"
+
+ Infoblox_UDDI_Api="https://$Infoblox_Portal/api/ddi/v1"
+ export _H1="Authorization: Token $Infoblox_UDDI_Key"
+ export _H2="Content-Type: application/json"
+
+ # Debug (masked)
+ _tok_len=$(printf "%s" "$Infoblox_UDDI_Key" | wc -c | tr -d ' \n')
+ _debug2 "Auth header set" "Token len=${_tok_len} on $Infoblox_Portal"
+
+ if [ "$method" != "GET" ]; then
+ _debug data "$data"
+ response="$(_post "$data" "$Infoblox_UDDI_Api/$ep" "" "$method")"
+ else
+ response="$(_get "$Infoblox_UDDI_Api/$ep")"
+ fi
+
+ _ret="$?"
+ _debug2 response "$response"
+
+ if [ "$_ret" != "0" ]; then
+ _err "Error: $ep"
+ return 1
+ fi
+
+ return 0
+}