@@ -112,6 +130,10 @@
|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)
|25|[](https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml)|Haiku OS
+|26|[](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml)|Tribblix
+|27|[](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml)|GhostBSD
+|28|[](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml)|GNU Hurd
+|29|[](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml)|openEuler
> 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest)
@@ -146,6 +168,7 @@
| 🌐 DNS mode | Use DNS TXT records |
| 🔗 [DNS alias mode](https://github.com/acmesh-official/acme.sh/wiki/DNS-alias-mode) | Use DNS alias for verification |
| 📡 [Stateless mode](https://github.com/acmesh-official/acme.sh/wiki/Stateless-Mode) | Stateless verification |
+| 📌 [DNS persist mode](https://github.com/acmesh-official/acme.sh/wiki/DNS-persist-mode) | Persistent DNS TXT record ([draft-ietf-acme-dns-persist-01](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/)) |
---
@@ -396,7 +419,53 @@ acme.sh --renew -d example.com
---
-### 🔟 Issue Certificates of Different Key Types (ECC or RSA)
+### 🔟 Use DNS Persist Mode
+
+📖 Wiki: https://github.com/acmesh-official/acme.sh/wiki/DNS-persist-mode
+
+📚 Spec: [draft-ietf-acme-dns-persist-01](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/)
+
+DNS persist mode lets you place a **single, long‑lived `_validation-persist` TXT record** in your zone and reuse it for every subsequent issuance and renewal. There is no per-issuance challenge token, so renewals require **no DNS edits** — useful when DNS API access is not available but you still want unattended renewals.
+
+#### 🪄 Step 1: Print the TXT record value
+
+```bash
+acme.sh --make-dns-persist-value -d example.com [--server letsencrypt] [--dns-persist-wildcard] [--dns-persist-ca-name "sectigo.com"] [--dns-persist-days 365]
+```
+
+Options:
+
+| Flag | Description |
+|------|-------------|
+| `--server ` | Pick the CA (default is your configured default). The account is registered automatically if you have not used this CA before. |
+| `--dns-persist-wildcard` | Adds `policy=wildcard` to the record so it also authorizes wildcard / subdomain certs. |
+| `--dns-persist-ca-name ` | Use a specific CA identity domain (e.g. `sectigo.com`). If omitted, identities are read from the ACME directory's `caaIdentities` field and one record per identity is printed — you only need to add **any one** of them. |
+| `--dns-persist-days ` | Adds `persistUntil=` to the record, set to N days from now. The CA will refuse new validations against the record after that time. Omit for a record with no expiry. |
+
+You should get an output like:
+
+```sh
+TXT persist domain:_validation-persist.example.com
+TXT persist value :"letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/123456789"
+```
+
+#### ✍️ Step 2: Add the TXT record to your DNS
+
+Add the printed `TXT persist domain` / `TXT persist value` pair as a TXT record at your DNS provider, then wait for it to propagate.
+
+#### 📜 Step 3: Issue the certificate
+
+```bash
+acme.sh --issue -d example.com --dns-persist
+```
+
+✅ **Done!** No challenge token is provisioned during issuance — the CA reads the persistent TXT record directly.
+
+> 🔄 Renewals just work: `acme.sh --renew -d example.com` (or the cron job) reuses the same TXT record automatically — no further DNS edits needed.
+
+---
+
+### 1️⃣1️⃣ Issue Certificates of Different Key Types (ECC or RSA)
Just set the `keylength` to a valid, supported value.
@@ -427,7 +496,7 @@ acme.sh --issue -w /home/wwwroot/example.com -d example.com -d www.example.com -
---
-### 1️⃣1️⃣ Issue Wildcard Certificates
+### 1️⃣2️⃣ Issue Wildcard Certificates
It's simple! Just give a wildcard domain as the `-d` parameter:
@@ -439,9 +508,9 @@ acme.sh --issue -d example.com -d '*.example.com' --dns dns_cf
---
-### 1️⃣2️⃣ How to Renew Certificates
+### 1️⃣3️⃣ How to Renew Certificates
-> 🔄 No need to renew manually! All certs will be renewed automatically every **30** days.
+> 🔄 No need to renew manually! All certs will be renewed automatically every **30** days, **or earlier when the CA's ARI says so** (see below).
However, you can force a renewal:
@@ -455,9 +524,43 @@ acme.sh --renew -d example.com --force
acme.sh --renew -d example.com --force --ecc
```
+#### 📡 ACME Renewal Information (ARI) — RFC 9773
+
+📖 Wiki: https://github.com/acmesh-official/acme.sh/wiki/ARI
+
+If the CA exposes a `renewalInfo` endpoint in its ACME directory (Let's Encrypt, ZeroSSL, etc.), `acme.sh` follows [RFC 9773](https://www.rfc-editor.org/rfc/rfc9773.html) automatically — **no flag needed, no opt-in**:
+
+| What | When | Why |
+|------|------|-----|
+| 🔍 **Polls `suggestedWindow`** | Every cron run, before deciding to skip | Lets the CA shift the renewal time forward in case of an incident (key compromise, mass revocation, etc.) |
+| 🎯 **Picks a random renewal time** inside the window | Right after a successful issuance/renewal | Disperses renewals across the network so all clients don't hit the CA at the same instant |
+| 🔗 **Sends `replaces=`** in `newOrder` | On renewal | Lets the CA correlate the new order with the certificate it supersedes (RFC 9773 §5) |
+| ↩️ **Retries without `replaces`** | If the CA rejects with `alreadyReplaced` or an ARI validation error | Robust against edge cases (e.g. switching CAs, retired issuers) |
+
+**Renewal trigger logic:** the cert is renewed if **any one** of the following becomes true:
+
+1. `--force` is given
+2. The CA's **ARI `suggestedWindow` has started**
+3. The cached `Le_NextRenewTime` has passed (default fallback for CAs without ARI)
+
+You can see the resulting next renewal time (already ARI-picked when applicable) in:
+
+```sh
+acme.sh --info -d example.com
+# Look for: Le_NextRenewTimeStr=...
+```
+
+For the live ARI window the CA is currently advertising, run with `--debug 2`:
+
+```sh
+acme.sh --renew -d example.com --debug 2 2>&1 | grep -i 'ARI suggestedWindow'
+```
+
+> 💡 If your CA does not advertise `renewalInfo`, `acme.sh` falls back to the classic 30-day rule — no behavior change.
+
---
-### 1️⃣3️⃣ How to Stop Certificate Renewal
+### 1️⃣4️⃣ How to Stop Certificate Renewal
To stop renewal of a cert, you can execute the following to remove the cert from the renewal list:
@@ -471,7 +574,7 @@ The cert/key file is not removed from the disk.
---
-### 1️⃣4️⃣ How to Upgrade acme.sh
+### 1️⃣5️⃣ How to Upgrade acme.sh
> 🚀 acme.sh is in constant development — it's strongly recommended to use the latest code.
@@ -495,25 +598,25 @@ acme.sh --upgrade --auto-upgrade 0
---
-### 1️⃣5️⃣ Issue a Certificate from an Existing CSR
+### 1️⃣6️⃣ Issue a Certificate from an Existing CSR
📚 https://github.com/acmesh-official/acme.sh/wiki/Issue-a-cert-from-existing-CSR
---
-### 1️⃣6️⃣ Send Notifications in Cronjob
+### 1️⃣7️⃣ Send Notifications in Cronjob
📚 https://github.com/acmesh-official/acme.sh/wiki/notify
---
-### 1️⃣7️⃣ Under the Hood
+### 1️⃣8️⃣ Under the Hood
> 🔧 Speak ACME language using shell, directly to "Let's Encrypt".
---
-### 1️⃣8️⃣ Acknowledgments
+### 1️⃣9️⃣ Acknowledgments
| Project | Link |
|---------|------|
@@ -530,6 +633,8 @@ This project exists thanks to all the people who contribute.
+If you want to become a contributor make sure to read [CONTRIBUTING.md](./CONTRIBUTING.md).
+
### 💰 Financial Contributors
Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/acmesh/contribute)]
@@ -555,7 +660,7 @@ Support this project with your organization. Your logo will show up here with a
---
-### 1️⃣9️⃣ License & Others
+### 2️⃣0️⃣ License & Others
📄 **License:** GPLv3
@@ -565,7 +670,7 @@ Support this project with your organization. Your logo will show up here with a
---
-### 2️⃣0️⃣ Donate
+### 2️⃣1️⃣ Donate
> 💝 Your donation makes **acme.sh** better!
@@ -577,7 +682,7 @@ Support this project with your organization. Your logo will show up here with a
---
-### 2️⃣1️⃣ About This Repository
+### 2️⃣2️⃣ 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!
diff --git a/acme.sh b/acme.sh
index e780f61d..4d4f6cbe 100755
--- a/acme.sh
+++ b/acme.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env sh
-VER=3.1.3
+VER=3.1.5
PROJECT_NAME="acme.sh"
@@ -59,6 +59,7 @@ DEFAULT_OPENSSL_BIN="openssl"
VTYPE_HTTP="http-01"
VTYPE_DNS="dns-01"
VTYPE_ALPN="tls-alpn-01"
+VTYPE_DNS_PERSIST="dns-persist-01"
ID_TYPE_DNS="dns"
ID_TYPE_IP="ip"
@@ -71,6 +72,7 @@ NO_VALUE="no"
W_DNS="dns"
W_ALPN="alpn"
+W_DNS_PERSIST="dns_persist"
DNS_ALIAS_PREFIX="="
MODE_STATELESS="stateless"
@@ -916,6 +918,15 @@ _json_decode() {
echo "$_j_str"
}
+#extract the authorization URLs from an order response on stdin, as a
+#comma-separated list. The entries are quoted URL strings and a quote cannot
+#occur inside a URL, so the first '"]' is always the end of the array. A
+#char-class scan would stop early on the brackets of an IPv6 host
+#(https://[2001:db8::1]/...). Outputs nothing if the field is missing.
+_authorizations_from_order() {
+ sed -n 's/.*"authorizations" *: *\[//p' | sed 's/" *\].*//' | tr -d '" '
+}
+
#options file
_sed_i() {
options="$1"
@@ -1013,6 +1024,24 @@ _checkcert() {
fi
}
+#file
+_enddate() {
+ _cf="$1"
+ _res="$(${ACME_OPENSSL_BIN:-openssl} x509 -noout -enddate -in "$_cf")"
+ if [ "$?" != "0" ] || [ -z "$_res" ]; then
+ return 1
+ fi
+
+ case "$_res" in
+ notAfter=*)
+ echo "${_res#notAfter=}"
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
#Usage: hashalg [outputhex]
#Output Base64-encoded digest
_digest() {
@@ -1037,6 +1066,25 @@ _digest() {
}
+#Usage: certpath hashalg
+#Output certificate fingerprint without colons
+_fingerprint() {
+ cert="$1"
+ alg="$2"
+ if [ -z "$alg" ]; then
+ _usage "Usage: _fingerprint certpath hashalg"
+ return 1
+ fi
+
+ if [ "$alg" = "sha256" ] || [ "$alg" = "sha1" ] || [ "$alg" = "md5" ]; then
+ # openssl prints "SHA1 Fingerprint=AA:BB:CC:..."; strip prefix and colons.
+ ${ACME_OPENSSL_BIN:-openssl} x509 -in "$cert" -noout -fingerprint -"$alg" | sed 's/.*=//; s/://g'
+ else
+ _err "$alg is not supported yet"
+ return 1
+ fi
+}
+
#Usage: hashalg secret_hex [outputhex]
#Output binary hmac
_hmac() {
@@ -1140,6 +1188,11 @@ _createkey() {
length="$1"
f="$2"
_debug2 "_createkey for file:$f"
+ if ! _exists "${ACME_OPENSSL_BIN:-openssl}"; then
+ _err "Please install openssl first. ACME_OPENSSL_BIN=$ACME_OPENSSL_BIN"
+ _err "We need openssl to generate keys."
+ return 1
+ fi
eccname="$length"
if _startswith "$length" "ec-"; then
length=$(printf "%s" "$length" | cut -d '-' -f 2-100)
@@ -1162,6 +1215,7 @@ _createkey() {
_debug "Using length $length"
+ _new_key_file=""
if ! [ -e "$f" ]; then
if ! touch "$f" >/dev/null 2>&1; then
_f_path="$(dirname "$f")"
@@ -1175,6 +1229,7 @@ _createkey() {
return 1
fi
chmod 600 "$f"
+ _new_key_file="1"
fi
if _isEccKey "$length"; then
@@ -1183,6 +1238,10 @@ _createkey() {
echo "$_opkey" >"$f"
else
_err "Error encountered for ECC key named $eccname"
+ #do not leave an empty file behind, or the next run would treat the key as existing
+ if [ "$_new_key_file" ]; then
+ rm -f "$f"
+ fi
return 1
fi
else
@@ -1195,6 +1254,10 @@ _createkey() {
echo "$_opkey" >"$f"
else
_err "Error encountered for RSA key of length $length"
+ #do not leave an empty file behind, or the next run would treat the key as existing
+ if [ "$_new_key_file" ]; then
+ rm -f "$f"
+ fi
return 1
fi
fi
@@ -1244,6 +1307,23 @@ _idn() {
}
#_createcsr cn san_list keyfile csrfile conf acmeValidationv1 extendedUsage
+#cn
+#The x509 Common Name is limited to 64 characters (RFC 5280 ub-common-name,
+#enforced by openssl in ASN1_mbstring_ncopy), and an IP address or an empty
+#name is not usable as CN either. When this rejects the name, _createcsr
+#omits CN from the CSR subject and the CA takes the identifiers from the
+#subjectAltName extension (issue 4867).
+_is_valid_cn() {
+ _cn_v="$1"
+ if [ -z "$_cn_v" ] || [ "${#_cn_v}" -gt 64 ]; then
+ return 1
+ fi
+ if _isIP "$_cn_v"; then
+ return 1
+ fi
+ return 0
+}
+
_createcsr() {
_debug _createcsr
domain="$1"
@@ -1307,16 +1387,16 @@ _createcsr() {
_csr_cn="$(_idn "$domain")"
_debug2 _csr_cn "$_csr_cn"
if _contains "$(uname -a)" "MINGW"; then
- if _isIP "$_csr_cn"; then
- ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//O=$PROJECT_NAME" -config "$csrconf" -out "$csr"
- else
+ if _is_valid_cn "$_csr_cn"; then
${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//CN=$_csr_cn" -config "$csrconf" -out "$csr"
+ else
+ ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//O=$PROJECT_NAME" -config "$csrconf" -out "$csr"
fi
else
- if _isIP "$_csr_cn"; then
- ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/O=$PROJECT_NAME" -config "$csrconf" -out "$csr"
- else
+ if _is_valid_cn "$_csr_cn"; then
${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/CN=$_csr_cn" -config "$csrconf" -out "$csr"
+ else
+ ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/O=$PROJECT_NAME" -config "$csrconf" -out "$csr"
fi
fi
}
@@ -1342,7 +1422,9 @@ _readSubjectFromCSR() {
_usage "_readSubjectFromCSR mycsr.csr"
return 1
fi
- ${ACME_OPENSSL_BIN:-openssl} req -noout -in "$_csrfile" -subject | tr ',' "\n" | _egrep_o "CN *=.*" | cut -d = -f 2 | cut -d / -f 1 | tr -d ' \n'
+ # -config /dev/null: reading a CSR needs no config, but a missing default
+ # openssl.cnf is fatal on some systems (e.g. NetBSD does not install one)
+ ${ACME_OPENSSL_BIN:-openssl} req -noout -in "$_csrfile" -subject -config /dev/null | tr ',' "\n" | _egrep_o "CN *=.*" | cut -d = -f 2 | cut -d / -f 1 | tr -d ' \n'
}
#_csrfile
@@ -1357,16 +1439,17 @@ _readSubjectAltNamesFromCSR() {
_csrsubj="$(_readSubjectFromCSR "$_csrfile")"
_debug _csrsubj "$_csrsubj"
- _dnsAltnames="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" | grep "^ *DNS:.*" | tr -d ' \n')"
+ _dnsAltnames="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" -config /dev/null | grep "^ *DNS:.*" | tr -d ' \n')"
_debug _dnsAltnames "$_dnsAltnames"
- if _contains "$_dnsAltnames," "DNS:$_csrsubj,"; then
+ # escape the wildcard '*' so it is not taken as a regex operator by grep/sed below
+ _escapedAltnames="$(echo "$_dnsAltnames" | tr '*' '#')"
+ _debug _escapedAltnames "$_escapedAltnames"
+ _escapedSubject="$(echo "$_csrsubj" | tr '*' '#')"
+ _debug _escapedSubject "$_escapedSubject"
+ if _contains "$_escapedAltnames," "DNS:$_escapedSubject,"; then
_debug "AltNames contains subject"
- _excapedAlgnames="$(echo "$_dnsAltnames" | tr '*' '#')"
- _debug _excapedAlgnames "$_excapedAlgnames"
- _escapedSubject="$(echo "$_csrsubj" | tr '*' '#')"
- _debug _escapedSubject "$_escapedSubject"
- _dnsAltnames="$(echo "$_excapedAlgnames," | sed "s/DNS:$_escapedSubject,//g" | tr '#' '*' | sed "s/,\$//g")"
+ _dnsAltnames="$(echo "$_escapedAltnames," | sed "s/DNS:$_escapedSubject,//g" | tr '#' '*' | sed "s/,\$//g")"
_debug _dnsAltnames "$_dnsAltnames"
else
_debug "AltNames doesn't contain subject"
@@ -1383,7 +1466,7 @@ _readKeyLengthFromCSR() {
return 1
fi
- _outcsr="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile")"
+ _outcsr="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" -config /dev/null)"
_debug2 _outcsr "$_outcsr"
if _contains "$_outcsr" "Public Key Algorithm: id-ecPublicKey"; then
_debug "ECC CSR"
@@ -1464,6 +1547,22 @@ _toPkcs() {
}
+_toPkcs8() {
+ _cpkcs8="$1"
+ _ckey="$2"
+ pkcs8Password="$3"
+
+ if [ "$pkcs8Password" ]; then
+ ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -v2 aes256 -passout "pass:$pkcs8Password" -in "$_ckey" -out "$_cpkcs8"
+ else
+ ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in "$_ckey" -out "$_cpkcs8"
+ fi
+ if [ "$?" = "0" ]; then
+ _savedomainconf "Le_PKCS8Password" "$pkcs8Password" "base64"
+ fi
+
+}
+
#domain [password] [isEcc]
toPkcs() {
domain="$1"
@@ -1485,20 +1584,21 @@ toPkcs() {
}
-#domain [isEcc]
+#domain [password] [isEcc]
toPkcs8() {
domain="$1"
+ pkcs8Password="$2"
if [ -z "$domain" ]; then
- _usage "Usage: $PROJECT_ENTRY --to-pkcs8 --domain [--ecc]"
+ _usage "Usage: $PROJECT_ENTRY --to-pkcs8 --domain [--password ] [--ecc]"
return 1
fi
- _isEcc="$2"
+ _isEcc="$3"
_initpath "$domain" "$_isEcc"
- ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in "$CERT_KEY_PATH" -out "$CERT_PKCS8_PATH"
+ _toPkcs8 "$CERT_PKCS8_PATH" "$CERT_KEY_PATH" "$pkcs8Password"
if [ "$?" = "0" ]; then
_info "Success, $CERT_PKCS8_PATH"
@@ -1832,22 +1932,89 @@ _date2time() {
if gdate -u -d "$(echo "$1" | tr -d "Z" | tr "T" ' ')" +"%s" 2>/dev/null; then
return
fi
- #Omnios
- if python3 -c "import datetime; print(int(datetime.datetime.strptime(\"$1\", \"%Y-%m-%d %H:%M:%S\").replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null; then
+ #Omnios. Pass the date as argv (sys.argv[1]) instead of interpolating it into
+ #the -c program text, so a quote in the input cannot inject Python code.
+ if python3 -c "import datetime,sys; print(int(datetime.datetime.strptime(sys.argv[1], \"%Y-%m-%d %H:%M:%S\").replace(tzinfo=datetime.timezone.utc).timestamp()))" "$1" 2>/dev/null; then
return
fi
#Omnios
- if python3 -c "import datetime; print(int(datetime.datetime.strptime(\"$1\", \"%Y-%m-%dT%H:%M:%SZ\").replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null; then
+ if python3 -c "import datetime,sys; print(int(datetime.datetime.strptime(sys.argv[1], \"%Y-%m-%dT%H:%M:%SZ\").replace(tzinfo=datetime.timezone.utc).timestamp()))" "$1" 2>/dev/null; then
return
fi
_err "Cannot parse _date2time $1"
return 1
}
+#support the output format of openssl -enddate:
+# Apr 01 08:10:33 2022 GMT to 1641283833
+_ssldate2time() {
+ #Linux
+ if date -u -d "$1" +"%s" 2>/dev/null; then
+ return
+ fi
+ #Solaris
+ if gdate -u -d "$1" +"%s" 2>/dev/null; then
+ return
+ fi
+ #Mac/BSD
+ if date -j -f "%b %d %T %Y %Z" "$1" +"%s" 2>/dev/null; then
+ return
+ fi
+ _err "Cannot parse _ssldate2time $1"
+ return 1
+}
+
_utc_date() {
date -u "+%Y-%m-%d %H:%M:%S"
}
+#Usage: _calc_next_renew_time createtime renewaldays [endtime]
+#Prints createtime + renewaldays*86400 - 86400, capped so it never passes
+#the certificate expiry: with short-lived certs (internal CAs, upcoming
+#CA/B SC-081 47-day maximum) a fixed RenewalDays would otherwise schedule
+#the renewal after notAfter and leave an expired cert in place.
+#The cap is one day before endtime, or one hour before for certs whose
+#lifetime is 24 hours or less, mirroring the --valid-to scheduling.
+_calc_next_renew_time() {
+ _cnrt_create="$1"
+ _cnrt_days="$2"
+ _cnrt_end="$3"
+ _cnrt_next=$(_math "$_cnrt_create" + "$_cnrt_days" \* 24 \* 60 \* 60 - 86400)
+ if [ -z "$_cnrt_end" ]; then
+ printf "%s" "$_cnrt_next"
+ return 0
+ fi
+ if [ "$(_math "$_cnrt_end" - "$_cnrt_create")" -gt 86400 ]; then
+ _cnrt_cap=$(_math "$_cnrt_end" - 86400)
+ else
+ _cnrt_cap=$(_math "$_cnrt_end" - 3600)
+ fi
+ if [ "$_cnrt_next" -gt "$_cnrt_cap" ]; then
+ _cnrt_next="$_cnrt_cap"
+ fi
+ printf "%s" "$_cnrt_next"
+}
+
+#Usage: _calc_validto_renew_time notaftertime renewaldays now
+#Prints the next renew time for a cert issued with a relative --valid-to.
+#A negative renewaldays is anchored to the expiry: notaftertime +
+#renewaldays*86400. Otherwise the cert renews one day before the expiry,
+#or one hour before for certs whose lifetime is 24 hours or less.
+_calc_validto_renew_time() {
+ _cvrt_end="$1"
+ _cvrt_days="$2"
+ _cvrt_now="$3"
+ if [ "$_cvrt_days" ] && [ "$_cvrt_days" -lt 0 ]; then
+ _math "$_cvrt_end" + "$_cvrt_days" \* 24 \* 60 \* 60
+ return 0
+ fi
+ if [ "$(_math "$_cvrt_end" - "$_cvrt_now")" -gt 86400 ]; then
+ _math "$_cvrt_end" - 86400
+ else
+ _math "$_cvrt_end" - 3600
+ fi
+}
+
_mktemp() {
if _exists mktemp; then
if mktemp 2>/dev/null; then
@@ -2474,6 +2641,13 @@ _savedeployconf() {
_cleardomainconf "$1"
}
+#key
+_cleardeployconf() {
+ _cleardomainconf "SAVED_$1"
+ #remove later
+ _cleardomainconf "$1"
+}
+
#key
_getdeployconf() {
_rac_key="$1"
@@ -2541,6 +2715,21 @@ _clearcaconf() {
_clear_conf "$CA_CONF" "$1"
}
+#Starts a socat listener in the background, the pid is set to _socat_pid.
+#It uses the content, _content_len, _NC and _SOCAT_ERR of _startserver.
+#options
+_startsocat() {
+ _socat_opts="$1"
+ _debug "_NC" "$_NC $_socat_opts"
+ $_NC $_socat_opts SYSTEM:"sleep 1; \
+echo 'HTTP/1.0 200 OK'; \
+echo 'Content-Length\: $_content_len'; \
+echo ''; \
+printf '%s' '$content';" 2>>"$_SOCAT_ERR" &
+ _socat_pid="$!"
+ _debug "_socat_pid" "$_socat_pid"
+}
+
# content localaddress
_startserver() {
content="$1"
@@ -2554,16 +2743,24 @@ _startserver() {
_debug Le_Listen_V4 "$Le_Listen_V4"
_debug Le_Listen_V6 "$Le_Listen_V6"
+ _serverproc_v6=""
if _exists "socat"; then
_NC="socat"
- if [ "$Le_Listen_V6" ]; then
+ SOCAT_OPTIONS6=""
+ if [ "$Le_Listen_V6" ] && [ -z "$Le_Listen_V4" ]; then
_NC="$_NC -6"
SOCAT_OPTIONS=TCP6-LISTEN
- elif [ "$Le_Listen_V4" ]; then
+ elif [ "$Le_Listen_V4" ] && [ -z "$Le_Listen_V6" ]; then
_NC="$_NC -4"
SOCAT_OPTIONS=TCP4-LISTEN
- else
+ elif [ "$ncaddr" ]; then
+ #a single local address belongs to a single family, let socat pick it
SOCAT_OPTIONS=TCP-LISTEN
+ else
+ #listen on both ipv4 and ipv6, with one socket for each family:
+ #ipv4-mapped ipv6 addresses are not available everywhere.
+ SOCAT_OPTIONS=TCP4-LISTEN
+ SOCAT_OPTIONS6=TCP6-LISTEN
fi
if [ "$DEBUG" ] && [ "$DEBUG" -gt "1" ]; then
@@ -2571,6 +2768,10 @@ _startserver() {
fi
SOCAT_OPTIONS=$SOCAT_OPTIONS:$Le_HTTPPort,crlf,reuseaddr,fork
+ if [ "$SOCAT_OPTIONS6" ]; then
+ #ipv6only keeps this socket from colliding with the ipv4 one
+ SOCAT_OPTIONS6=$SOCAT_OPTIONS6:$Le_HTTPPort,crlf,reuseaddr,fork,ipv6only=1
+ fi
#Adding bind to local-address
if [ "$ncaddr" ]; then
@@ -2579,14 +2780,14 @@ _startserver() {
_content_len="$(printf "%s" "$content" | wc -c)"
_debug _content_len "$_content_len"
- _debug "_NC" "$_NC $SOCAT_OPTIONS"
export _SOCAT_ERR="$(_mktemp)"
- $_NC $SOCAT_OPTIONS SYSTEM:"sleep 1; \
-echo 'HTTP/1.0 200 OK'; \
-echo 'Content-Length\: $_content_len'; \
-echo ''; \
-printf '%s' '$content';" 2>"$_SOCAT_ERR" &
- serverproc="$!"
+ _startsocat "$SOCAT_OPTIONS"
+ serverproc="$_socat_pid"
+ if [ "$SOCAT_OPTIONS6" ]; then
+ #best effort, the host may have no ipv6 support at all
+ _startsocat "$SOCAT_OPTIONS6"
+ _serverproc_v6="$_socat_pid"
+ fi
else
_PYTHON=""
if _exists "python3"; then
@@ -2598,21 +2799,40 @@ printf '%s' '$content';" 2>"$_SOCAT_ERR" &
fi
if [ "$_PYTHON" ]; then
_debug "Using python: $_PYTHON"
- _AF="socket.AF_INET"
- _BIND_ADDR="0.0.0.0"
- if [ "$Le_Listen_V6" ]; then
- _AF="socket.AF_INET6"
+ #a comma separated list of addresses to listen on, one socket for each
+ _BIND_ADDR="0.0.0.0,::"
+ if [ "$Le_Listen_V6" ] && [ -z "$Le_Listen_V4" ]; then
_BIND_ADDR="::"
+ elif [ "$Le_Listen_V4" ] && [ -z "$Le_Listen_V6" ]; then
+ _BIND_ADDR="0.0.0.0"
fi
if [ "$ncaddr" ]; then
_BIND_ADDR="$ncaddr"
fi
+ _debug "_BIND_ADDR" "$_BIND_ADDR"
export _SOCAT_ERR="$(_mktemp)"
- $_PYTHON -c "import socket,sys;s=socket.socket($_AF,socket.SOCK_STREAM);s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);s.bind((sys.argv[2],int(sys.argv[1])));s.listen(5);res='HTTP/1.0 200 OK\r\nContent-Length: '+str(len(sys.argv[3]))+'\r\n\r\n'+sys.argv[3];
+ $_PYTHON -c "import socket,sys,select
+res='HTTP/1.0 200 OK\r\nContent-Length: '+str(len(sys.argv[3]))+'\r\n\r\n'+sys.argv[3]
+ads=sys.argv[2].split(',')
+ls=[]
+for ad in ads:
+ try:
+ sk=socket.socket(socket.AF_INET6 if ':' in ad else socket.AF_INET,socket.SOCK_STREAM)
+ sk.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
+ if ':' in ad and len(ads)>1:
+ sk.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1)
+ sk.bind((ad,int(sys.argv[1])))
+ sk.listen(5)
+ ls.append(sk)
+ except Exception:
+ sys.stderr.write(str(sys.exc_info()[1])+'\n')
+if not ls:
+ sys.exit(1)
while True:
- c,a=s.accept()
- c.sendall(res.encode() if hasattr(res, 'encode') else res)
- c.close()" "$Le_HTTPPort" "$_BIND_ADDR" "$content" 2>"$_SOCAT_ERR" &
+ for sk in select.select(ls,[],[])[0]:
+ c,a=sk.accept()
+ c.sendall(res.encode() if hasattr(res, 'encode') else res)
+ c.close()" "$Le_HTTPPort" "$_BIND_ADDR" "$content" 2>"$_SOCAT_ERR" &
serverproc="$!"
_NC="$_PYTHON"
else
@@ -2635,6 +2855,11 @@ while True:
_stopserver() {
pid="$1"
_debug "pid" "$pid"
+ if [ "$_serverproc_v6" ]; then
+ _debug "_serverproc_v6" "$_serverproc_v6"
+ kill $_serverproc_v6 >/dev/null 2>&1
+ _serverproc_v6=""
+ fi
if [ -z "$pid" ]; then
rm -f "$_SOCAT_ERR"
return
@@ -2708,9 +2933,11 @@ _starttlsserver() {
_debug Le_Listen_V4 "$Le_Listen_V4"
_debug Le_Listen_V6 "$Le_Listen_V6"
- if [ "$Le_Listen_V4" ]; then
+ #openssl s_server binds a single socket, so both options together can only
+ #mean: do not force a family, same as when neither of them is given.
+ if [ "$Le_Listen_V4" ] && [ -z "$Le_Listen_V6" ]; then
__S_OPENSSL="$__S_OPENSSL -4"
- elif [ "$Le_Listen_V6" ]; then
+ elif [ "$Le_Listen_V6" ] && [ -z "$Le_Listen_V4" ]; then
__S_OPENSSL="$__S_OPENSSL -6"
fi
@@ -2776,14 +3003,45 @@ __initHome() {
_debug "Using default home: $DEFAULT_INSTALL_HOME"
LE_WORKING_DIR="$DEFAULT_INSTALL_HOME"
fi
+ # Convert a relative --home to an absolute path: later code cd's around
+ # (e.g. installOnline extracts and enters the archive dir), where a
+ # relative path would point into the wrong directory.
+ # https://github.com/acmesh-official/acme.sh/issues/6477
+ case "$LE_WORKING_DIR" in
+ /*) ;;
+ *)
+ if [ -d "$LE_WORKING_DIR" ]; then
+ LE_WORKING_DIR="$(cd "$LE_WORKING_DIR" && pwd)"
+ fi
+ ;;
+ esac
export LE_WORKING_DIR
if [ -z "$LE_CONFIG_HOME" ]; then
LE_CONFIG_HOME="$LE_WORKING_DIR"
fi
+ case "$LE_CONFIG_HOME" in
+ /*) ;;
+ *)
+ if [ -d "$LE_CONFIG_HOME" ]; then
+ LE_CONFIG_HOME="$(cd "$LE_CONFIG_HOME" && pwd)"
+ fi
+ ;;
+ esac
_debug "Using config home: $LE_CONFIG_HOME"
export LE_CONFIG_HOME
+ # Paths with whitespace break the unquoted $_CURL/$_WGET command expansion,
+ # so fail early with a clear error instead of a cryptic curl/wget failure.
+ # https://github.com/acmesh-official/acme.sh/issues/2163
+ case "$LE_WORKING_DIR$LE_CONFIG_HOME" in
+ *" "*)
+ _err "The --home or --config-home path can not contain spaces: '$LE_WORKING_DIR'"
+ _err "Please install $PROJECT_NAME to a path without spaces."
+ exit 1
+ ;;
+ esac
+
_DEFAULT_ACCOUNT_CONF_PATH="$LE_CONFIG_HOME/account.conf"
if [ -z "$ACCOUNT_CONF_PATH" ]; then
@@ -2879,6 +3137,11 @@ _initAPI() {
return 0
fi
_err "Cannot init API for $_api_server"
+ if [ "$_api_server" = "$CA_ZEROSSL" ]; then
+ _info "$(__green "If this host is IPv6-only: ZeroSSL currently has no IPv6 endpoint.")"
+ _info "$(__green "Try another CA, e.g.: $PROJECT_ENTRY --set-default-ca --server letsencrypt")"
+ _info "See: $(__green "https://github.com/acmesh-official/acme.sh/issues/6872")"
+ fi
return 1
}
@@ -3309,9 +3572,14 @@ _setNginx() {
fi
echo "$NGINX_START
-location ~ \"^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)\$\" {
- default_type text/plain;
- return 200 \"\$1.$_thumbpt\";
+location ^~ /.well-known/acme-challenge/ {
+ # the ^~ prefix wins over regex-skipping blocks like \"location ^~ /\",
+ # the nested regex location still captures the token as \$1
+ location ~ \"^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)\$\" {
+ default_type text/plain;
+ return 200 \"\$1.$_thumbpt\";
+ }
+ return 404;
}
#NGINX_START
" >>"$FOUND_REAL_NGINX_CONF"
@@ -3455,7 +3723,7 @@ _restoreNginx() {
done
_info "Reloading nginx"
- if ! nginx -s reload >/dev/null; then
+ if ! nginx -s reload >/dev/null 2>&1; then
_err "An error occurred while reloading nginx, please open an issue on $PROJECT."
return 1
fi
@@ -3759,10 +4027,10 @@ _on_issue_success() {
#account_key_length eab-kid eab-hmac-key
registeraccount() {
_account_key_length="$1"
- _eab_id="$2"
+ _eab_kid="$2"
_eab_hmac_key="$3"
_initpath
- _regAccount "$_account_key_length" "$_eab_id" "$_eab_hmac_key"
+ _regAccount "$_account_key_length" "$_eab_kid" "$_eab_hmac_key"
}
__calcAccountKeyHash() {
@@ -3773,6 +4041,16 @@ __calc_account_thumbprint() {
printf "%s" "$jwk" | tr -d ' ' | _digest "sha256" | _url_replace
}
+#Reads a comma- or space-separated email list from stdin and prints
+#the ACME contact list items: "mailto:a@example.com","mailto:b@example.com"
+_mailto_contacts() {
+ _mc_out=""
+ for _mc_m in $(tr ',' ' '); do
+ _mc_out="$_mc_out,\"mailto:$_mc_m\""
+ done
+ echo "$_mc_out" | cut -c 2-
+}
+
_getAccountEmail() {
if [ "$ACCOUNT_EMAIL" ]; then
echo "$ACCOUNT_EMAIL"
@@ -3792,14 +4070,14 @@ _getAccountEmail() {
_regAccount() {
_initpath
_reg_length="$1"
- _eab_id="$2"
+ _eab_kid="$2"
_eab_hmac_key="$3"
_debug3 _regAccount "$_regAccount"
_initAPI
mkdir -p "$CA_DIR"
- if [ ! -f "$ACCOUNT_KEY_PATH" ]; then
+ if [ ! -s "$ACCOUNT_KEY_PATH" ]; then
if ! _create_account_key "$_reg_length"; then
_err "Error creating account key."
return 1
@@ -3809,13 +4087,13 @@ _regAccount() {
if ! _calcjwk "$ACCOUNT_KEY_PATH"; then
return 1
fi
- if [ "$_eab_id" ] && [ "$_eab_hmac_key" ]; then
- _savecaconf CA_EAB_KEY_ID "$_eab_id"
+ if [ "$_eab_kid" ] && [ "$_eab_hmac_key" ]; then
+ _savecaconf CA_EAB_KEY_ID "$_eab_kid"
_savecaconf CA_EAB_HMAC_KEY "$_eab_hmac_key"
fi
- _eab_id=$(_readcaconf "CA_EAB_KEY_ID")
+ _eab_kid=$(_readcaconf "CA_EAB_KEY_ID")
_eab_hmac_key=$(_readcaconf "CA_EAB_HMAC_KEY")
- _secure_debug3 _eab_id "$_eab_id"
+ _secure_debug3 _eab_kid "$_eab_kid"
_secure_debug3 _eab_hmac_key "$_eab_hmac_key"
_email="$(_getAccountEmail)"
if [ "$_email" ]; then
@@ -3823,7 +4101,7 @@ _regAccount() {
fi
if [ "$ACME_DIRECTORY" = "$CA_ZEROSSL" ]; then
- if [ -z "$_eab_id" ] || [ -z "$_eab_hmac_key" ]; then
+ if [ -z "$_eab_kid" ] || [ -z "$_eab_hmac_key" ]; then
_info "No EAB credentials found for ZeroSSL, let's obtain them"
if [ -z "$_email" ]; then
_info "$(__green "$PROJECT_NAME is using ZeroSSL as default CA now.")"
@@ -3832,17 +4110,19 @@ _regAccount() {
_info "See: $(__green "$_ZEROSSL_WIKI")"
return 1
fi
- _eabresp=$(_post "email=$_email" $_ZERO_EAB_ENDPOINT)
+ #the ZeroSSL EAB endpoint takes a single address, use the first one
+ _eab_email="$(echo "$_email" | tr ',' ' ' | awk '{print $1}')"
+ _eabresp=$(_post "email=$_eab_email" $_ZERO_EAB_ENDPOINT)
if [ "$?" != "0" ]; then
_debug2 "$_eabresp"
_err "Cannot get EAB credentials from ZeroSSL."
return 1
fi
_secure_debug2 _eabresp "$_eabresp"
- _eab_id="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_kid"' | cut -d : -f 2 | tr -d '"')"
- _secure_debug2 _eab_id "$_eab_id"
- if [ -z "$_eab_id" ]; then
- _err "Cannot resolve _eab_id"
+ _eab_kid="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_kid"' | cut -d : -f 2 | tr -d '"')"
+ _secure_debug2 _eab_kid "$_eab_kid"
+ if [ -z "$_eab_kid" ]; then
+ _err "Cannot resolve _eab_kid"
return 1
fi
_eab_hmac_key="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_hmac_key"' | cut -d : -f 2 | tr -d '"')"
@@ -3851,12 +4131,12 @@ _regAccount() {
_err "Cannot resolve _eab_hmac_key"
return 1
fi
- _savecaconf CA_EAB_KEY_ID "$_eab_id"
+ _savecaconf CA_EAB_KEY_ID "$_eab_kid"
_savecaconf CA_EAB_HMAC_KEY "$_eab_hmac_key"
fi
fi
- if [ "$_eab_id" ] && [ "$_eab_hmac_key" ]; then
- eab_protected="{\"alg\":\"HS256\",\"kid\":\"$_eab_id\",\"url\":\"${ACME_NEW_ACCOUNT}\"}"
+ if [ "$_eab_kid" ] && [ "$_eab_hmac_key" ]; then
+ eab_protected="{\"alg\":\"HS256\",\"kid\":\"$_eab_kid\",\"url\":\"${ACME_NEW_ACCOUNT}\"}"
_debug3 eab_protected "$eab_protected"
eab_protected64=$(printf "%s" "$eab_protected" | _base64 | _url_replace)
@@ -3870,6 +4150,10 @@ _regAccount() {
key_hex="$(_durl_replace_base64 "$_eab_hmac_key" | _dbase64 | _hex_dump | tr -d ' ')"
_debug3 key_hex "$key_hex"
+ if [ -z "$key_hex" ]; then
+ _err "Cannot base64-decode the eab-hmac-key. Please check the value, and your openssl version."
+ return 1
+ fi
eab_signature=$(printf "%s" "$eab_sign_t" | _hmac sha256 $key_hex | _base64 | _url_replace)
_debug3 eab_signature "$eab_signature"
@@ -3878,7 +4162,7 @@ _regAccount() {
_debug3 externalBinding "$externalBinding"
fi
if [ "$_email" ]; then
- email_sg="\"contact\": [\"mailto:$_email\"], "
+ email_sg="\"contact\": [$(echo "$_email" | _mailto_contacts)], "
fi
regjson="{$email_sg\"termsOfServiceAgreed\": true$externalBinding}"
@@ -3914,7 +4198,7 @@ _regAccount() {
fi
_savecaconf "ACCOUNT_URL" "$_accUri"
else
- ACCOUNT_URL="$(_readcaconf ACCOUNT_URL)"
+ _accUri="$(_readcaconf ACCOUNT_URL)"
fi
export ACCOUNT_URL="$_accUri"
@@ -3922,7 +4206,9 @@ _regAccount() {
_debug "Calc CA_KEY_HASH" "$CA_KEY_HASH"
_savecaconf CA_KEY_HASH "$CA_KEY_HASH"
- if [ "$code" = '403' ]; then
+ #RFC 8555 sec 7.3.6 requires 401 for requests from a deactivated account,
+ #but Boulder (Let's Encrypt) historically returns 403. Accept both.
+ if [ "$code" = '403' ] || [ "$code" = '401' ]; then
_err "It seems that the account key has been deactivated, please use a new account key."
return 1
fi
@@ -3956,7 +4242,7 @@ updateaccount() {
_email="$(_getAccountEmail)"
if [ "$_email" ]; then
- updjson='{"contact": ["mailto:'$_email'"]}'
+ updjson='{"contact": ['$(echo "$_email" | _mailto_contacts)']}'
else
updjson='{"contact": []}'
fi
@@ -3966,6 +4252,12 @@ updateaccount() {
if [ "$code" = '200' ]; then
echo "$response" >"$ACCOUNT_JSON_PATH"
_info "Account update success for $_accUri."
+ # persist the effective mailbox like _regAccount does; otherwise
+ # "--update-account -m new@..." updates the CA but the local conf
+ # keeps showing the old address (issue 4673)
+ if [ "$_email" ]; then
+ _savecaconf "CA_EMAIL" "$_email"
+ fi
ACCOUNT_THUMBPRINT="$(__calc_account_thumbprint)"
_info "ACCOUNT_THUMBPRINT" "$ACCOUNT_THUMBPRINT"
@@ -3975,6 +4267,93 @@ updateaccount() {
fi
}
+#Implement account key rollover
+updateaccountkey() {
+ _length="$1"
+ _initpath
+
+ if [ ! -f "$ACCOUNT_KEY_PATH" ]; then
+ _err "Account key not found at: $ACCOUNT_KEY_PATH"
+ return 1
+ fi
+ ACCOUNT_KEY_PATH_NEW="$ACCOUNT_KEY_PATH.new"
+
+ _accUri=$(_readcaconf "ACCOUNT_URL")
+ _debug _accUri "$_accUri"
+
+ if [ -z "$_accUri" ]; then
+ _err "The account URL is empty, please run '--update-account' first to update the account info, then try again."
+ return 1
+ fi
+ if ! _calcjwk "$ACCOUNT_KEY_PATH"; then
+ return 1
+ fi
+ _inner_payload="{\"account\": \"$_accUri\", \"oldKey\": $jwk}"
+
+ _initAPI
+ if [ -z "$ACME_KEY_CHANGE" ]; then
+ _err "Server does not expose keyChange url."
+ return 1
+ fi
+
+ _url="$ACME_KEY_CHANGE"
+ if _createkey "$_length" "$ACCOUNT_KEY_PATH_NEW"; then
+ _info "New account key creation OK."
+ else
+ _err "New account key creation error."
+ return 1
+ fi
+
+ if ! _calcjwk "$ACCOUNT_KEY_PATH_NEW"; then
+ rm -f "$ACCOUNT_KEY_PATH_NEW"
+ return 1
+ fi
+ _inner_protected="{\"url\": \"${_url}$JWK_HEADERPLACE_PART2, \"jwk\": $jwk"'}'
+ _inner_protected64="$(printf "%s" "$_inner_protected" | _base64 | _url_replace)"
+ _inner_payload64="$(printf "%s" "$_inner_payload" | _base64 | _url_replace)"
+ if ! _inner_sig_t="$(printf "%s" "$_inner_protected64.$_inner_payload64" | _sign "$ACCOUNT_KEY_PATH_NEW" "sha256")"; then
+ _err "Sign request failed."
+ rm -f "$ACCOUNT_KEY_PATH_NEW"
+ return 1
+ fi
+ _debug3 _inner_sig_t "$_inner_sig_t"
+
+ _inner_sig="$(printf "%s" "$_inner_sig_t" | _url_replace)"
+ _debug3 _inner_sig "$_inner_sig"
+
+ _body="{\"protected\": \"$_inner_protected64\", \"payload\": \"$_inner_payload64\", \"signature\": \"$_inner_sig\"}"
+
+ if ! _send_signed_request "$_url" "$_body" "" "$ACCOUNT_KEY_PATH"; then
+ _err "Error rotating account key: $response."
+ rm -f "$ACCOUNT_KEY_PATH_NEW"
+ return 1
+ fi
+
+ if [ "$code" = '200' ]; then
+ echo "$response" >"$ACCOUNT_JSON_PATH"
+ mv -f "$ACCOUNT_KEY_PATH_NEW" "$ACCOUNT_KEY_PATH"
+ _info "Account key rotation success for $_accUri."
+ elif [ "$code" = "409" ]; then
+ _err "An existing account is using the new key"
+ rm -f "$ACCOUNT_KEY_PATH_NEW"
+ return 1
+ else
+ _err "Account key rollover error: $response"
+ rm -f "$ACCOUNT_KEY_PATH_NEW"
+ return 1
+ fi
+
+ __CACHED_JWK_KEY_FILE=""
+ _calcjwk "$ACCOUNT_KEY_PATH"
+
+ ACCOUNT_THUMBPRINT="$(__calc_account_thumbprint)"
+ _info "ACCOUNT_THUMBPRINT" "$ACCOUNT_THUMBPRINT"
+
+ CA_KEY_HASH="$(__calcAccountKeyHash)"
+ _debug "Calc CA_KEY_HASH" "$CA_KEY_HASH"
+ _savecaconf CA_KEY_HASH "$CA_KEY_HASH"
+}
+
#Implement deactivate account
deactivateaccount() {
_initpath
@@ -4002,7 +4381,8 @@ deactivateaccount() {
if _send_signed_request "$_accUri" "$_djson" && _contains "$response" '"deactivated"'; then
_info "Successfully deactivated account $_accUri."
_accid=$(echo "$response" | _egrep_o "\"id\" *: *[^,]*," | cut -d : -f 2 | tr -d ' ,')
- elif [ "$code" = "403" ]; then
+ elif [ "$code" = "403" ] || [ "$code" = "401" ]; then
+ #RFC 8555 sec 7.3.6: 401 from a deactivated account; Boulder returns 403
_info "The account is already deactivated."
_accid=$(_getfield "$_accUri" "999" "/")
else
@@ -4028,6 +4408,134 @@ deactivateaccount() {
fi
}
+#domain
+#Print the Validation Domain Name where the persistent TXT record must be
+#published: the "_validation-persist" label prepended to the domain being
+#validated (draft-ietf-acme-dns-persist-01 sec 4).
+#A wildcard identifier is validated by the record at its base domain, so the
+#leading "*." label is dropped: the wildcard scope comes from 'policy=wildcard'
+#in the record value, not from a "*" label in the record name (sec 5.1, 10.2).
+_dns_persist_txt_name() {
+ _dpt_domain="$1"
+ if _startswith "$_dpt_domain" "*."; then
+ _dpt_domain="$(echo "$_dpt_domain" | sed 's/^\*\.//')"
+ fi
+ if [ -z "$_dpt_domain" ]; then
+ return 1
+ fi
+ echo "_validation-persist.$_dpt_domain"
+}
+
+#domain wildcard ca_name days
+#Print the TXT record(s) the user must add to enable persistent DNS validation
+#per draft-ietf-acme-dns-persist-01.
+makednspersistvalue() {
+ _mdpv_domain="$1"
+ _mdpv_wildcard="$2"
+ _mdpv_ca_name="$3"
+ _mdpv_days="$4"
+
+ if [ -z "$_mdpv_domain" ]; then
+ _err "Please specify a domain with -d."
+ return 1
+ fi
+
+ _txt_name="$(_dns_persist_txt_name "$_mdpv_domain")"
+ if [ -z "$_txt_name" ]; then
+ _err "Invalid domain: $_mdpv_domain"
+ return 1
+ fi
+ _debug _txt_name "$_txt_name"
+
+ #A wildcard identifier can only be issued if the record carries
+ #'policy=wildcard', so don't print a record that is guaranteed to fail.
+ if _startswith "$_mdpv_domain" "*." && [ "$_mdpv_wildcard" != "1" ]; then
+ _info "$_mdpv_domain is a wildcard domain, adding 'policy=wildcard' automatically."
+ _mdpv_wildcard="1"
+ fi
+
+ if [ -n "$_mdpv_days" ]; then
+ case "$_mdpv_days" in
+ '' | *[!0-9]*)
+ _err "--dns-persist-days must be a positive integer, got: $_mdpv_days"
+ return 1
+ ;;
+ esac
+ if [ "$_mdpv_days" -lt 1 ]; then
+ _err "--dns-persist-days must be at least 1."
+ return 1
+ fi
+ fi
+
+ _initpath
+
+ _accUri="$(_readcaconf ACCOUNT_URL)"
+ if [ -z "$_accUri" ]; then
+ _info "No account is registered for $ACME_DIRECTORY yet, registering one now..."
+ if ! _regAccount "$DEFAULT_ACCOUNT_KEY_LENGTH"; then
+ _err "Cannot register account."
+ return 1
+ fi
+ _accUri="$(_readcaconf ACCOUNT_URL)"
+ fi
+
+ if [ -z "$_accUri" ]; then
+ _err "Cannot determine the ACME account URL."
+ return 1
+ fi
+ _debug "Account URL" "$_accUri"
+
+ _txt_suffix="; accounturi=$_accUri"
+ if [ "$_mdpv_wildcard" = "1" ]; then
+ _txt_suffix="$_txt_suffix; policy=wildcard"
+ fi
+ if [ -n "$_mdpv_days" ]; then
+ _persist_until=$(_math "$(_time)" + "$_mdpv_days" \* 86400)
+ _txt_suffix="$_txt_suffix; persistUntil=$_persist_until"
+ _info "persistUntil set to $(__green "$(_time2str "$_persist_until")") ($_mdpv_days days from now)"
+ fi
+
+ if [ -n "$_mdpv_ca_name" ]; then
+ _info ""
+ _info "Add the following DNS TXT record to enable persistent DNS validation:"
+ _info ""
+ _info "$(printf 'TXT persist domain:%s' "$(__green "$_txt_name")")"
+ _info "$(printf 'TXT persist value :%s' "$(__green "\"$_mdpv_ca_name$_txt_suffix\"")")"
+ _info ""
+ return 0
+ fi
+
+ _info "Fetching ACME directory: $ACME_DIRECTORY"
+ _dir_resp="$(_get "$ACME_DIRECTORY" "" 30)"
+ if [ "$?" != "0" ] || [ -z "$_dir_resp" ]; then
+ _err "Cannot fetch ACME directory: $ACME_DIRECTORY"
+ return 1
+ fi
+ _dir_resp="$(echo "$_dir_resp" | _json_decode)"
+ _debug2 _dir_resp "$_dir_resp"
+
+ _caa_array="$(echo "$_dir_resp" | tr -d ' \r\n\t' | _egrep_o '"caaIdentities":\[[^]]*\]')"
+ _debug2 _caa_array "$_caa_array"
+ _caaids="$(echo "$_caa_array" | sed 's/.*\[//' | sed 's/\].*//' | tr ',' '\n' | tr -d '"')"
+ _debug2 _caaids "$_caaids"
+
+ if [ -z "$_caaids" ]; then
+ _err "The directory does not include 'caaIdentities'. Please specify --dns-persist-ca-name explicitly."
+ return 1
+ fi
+
+ _info ""
+ _info "Add ANY ONE of the following DNS TXT records to enable persistent DNS validation."
+ _info "(You only need to add one; pick whichever issuer identity you prefer.)"
+ for _id in $_caaids; do
+ [ -z "$_id" ] && continue
+ _info ""
+ _info "$(printf 'TXT persist domain:%s' "$(__green "$_txt_name")")"
+ _info "$(printf 'TXT persist value :%s' "$(__green "\"$_id$_txt_suffix\"")")"
+ done
+ _info ""
+}
+
# domain folder file
_findHook() {
_hookdomain="$1"
@@ -4377,16 +4885,25 @@ _match_issuer() {
#ip
_isIPv4() {
- for seg in $(echo "$1" | tr '.' ' '); do
- _debug2 seg "$seg"
- if [ "$(echo "$seg" | tr -d '[0-9]')" ]; then
- #not all number
+ #splitting must not glob: a "*" segment would match files in cwd
+ set -f
+ _ipv4_saved_ifs="$IFS"
+ IFS='.'
+ # shellcheck disable=SC2086
+ set -- $1
+ IFS="$_ipv4_saved_ifs"
+ set +f
+ if [ $# -ne 4 ]; then
+ return 1
+ fi
+ for _ipv4_seg in "$@"; do
+ _debug2 _ipv4_seg "$_ipv4_seg"
+ case "$_ipv4_seg" in
+ *[!0-9]* | "") return 1 ;;
+ esac
+ if [ "${#_ipv4_seg}" -gt 3 ] || [ "$_ipv4_seg" -gt 255 ]; then
return 1
fi
- if [ $seg -ge 0 ] && [ $seg -lt 256 ]; then
- continue
- fi
- return 1
done
return 0
}
@@ -4485,11 +5002,18 @@ issue() {
if [ -z "$_ACME_IS_RENEW" ]; then
_initpath "$_main_domain" "$_key_length"
mkdir -p "$DOMAIN_PATH"
- elif ! _hasfield "$_web_roots" "$W_DNS"; then
+ elif [ -z "$Le_Vlist" ]; then
+ # Whether the saved order is resumed is decided by Le_Vlist below, so key
+ # this on Le_Vlist too. With no pending order to resume a new one is
+ # created, and a stale order link from the previous issuance must not be
+ # reused. https://github.com/acmesh-official/acme.sh/issues/3635
Le_OrderFinalize=""
Le_LinkOrder=""
- Le_LinkCert=""
fi
+ # Per-run state only: it is set after finalize and never read back from the
+ # saved domain conf. Carrying it over would make a run that gives up while
+ # the order is still 'processing' download the previous certificate again.
+ Le_LinkCert=""
if _hasfield "$_web_roots" "$W_DNS" && [ -z "$FORCE_DNS_MANUAL" ]; then
_err "$_DNS_MANUAL_ERROR"
@@ -4499,7 +5023,7 @@ issue() {
if [ -f "$DOMAIN_CONF" ]; then
Le_NextRenewTime=$(_readdomainconf Le_NextRenewTime)
_debug Le_NextRenewTime "$Le_NextRenewTime"
- if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then
+ if [ -z "$FORCE" ] && [ -z "$_ari_should_renew" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then
_valid_to_saved=$(_readdomainconf Le_Valid_To)
if [ "$_valid_to_saved" ] && ! _startswith "$_valid_to_saved" "+"; then
_info "The domain is set to be valid to: $_valid_to_saved"
@@ -4551,6 +5075,13 @@ issue() {
else
_cleardomainconf "Le_ChallengeAlias"
fi
+ # Save Le_DNSSleep unconditionally here: the save inside the dns_entries
+ # branch is skipped when all authorizations are already valid (e.g. issuing
+ # the ECC twin of a just-issued RSA cert), which left the setting out of
+ # that cert's conf. https://github.com/acmesh-official/acme.sh/issues/6986
+ if [ "$Le_DNSSleep" ]; then
+ _savedomainconf "Le_DNSSleep" "$Le_DNSSleep"
+ fi
if [ "$_preferred_chain" ]; then
_savedomainconf "Le_Preferred_Chain" "$_preferred_chain" "base64"
else
@@ -4580,7 +5111,7 @@ issue() {
_debug2 _saved_account_key_hash "$_saved_account_key_hash"
if [ -z "$ACCOUNT_URL" ] || [ -z "$_saved_account_key_hash" ] || [ "$_saved_account_key_hash" != "$(__calcAccountKeyHash)" ]; then
- if ! _regAccount "$_accountkeylength"; then
+ if ! _regAccount "$_accountkeylength" "$_eab_kid" "$_eab_hmac_key"; then
_on_issue_err "$_post_hook"
return 1
fi
@@ -4709,13 +5240,45 @@ issue() {
if [ "$_certificate_profile" ]; then
_newOrderObj="$_newOrderObj,\"profile\": \"$_certificate_profile\""
fi
+
+ # RFC 9773 Section 5: include "replaces" only when this is an actual
+ # renewal (--renew path), the CA advertises renewalInfo, and a prior
+ # cert exists. --issue (even with --force) is not a renewal per RFC 9773
+ # which speaks of "a clear predecessor certificate" issued by this CA.
+ # NO_ARI=1 (env, account.conf, or ca.conf) disables ARI entirely, so the
+ # "replaces" field is also omitted.
+ _replaces_certID=""
+ if [ "$NO_ARI" = "1" ]; then
+ _debug "NO_ARI=1, omitting ARI 'replaces' field from newOrder"
+ elif [ "$_ACME_IS_RENEW" = "1" ] && [ "$ACME_RENEWAL_INFO" ] && [ -f "$CERT_PATH" ]; then
+ _replaces_certID="$(_getARICertID "$CERT_PATH")"
+ _debug "Adding ARI replaces" "$_replaces_certID"
+ fi
+
_debug "STEP 1, Ordering a Certificate"
- if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderObj}"; then
+ _newOrderReplacesObj="$_newOrderObj"
+ if [ "$_replaces_certID" ]; then
+ _newOrderReplacesObj="$_newOrderObj,\"replaces\": \"$_replaces_certID\""
+ fi
+ if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderReplacesObj}"; then
_err "Error creating new order."
_clearup
_on_issue_err "$_post_hook"
return 1
fi
+ # RFC 9773 Section 5 only defines the "alreadyReplaced" error, but real CAs
+ # (Let's Encrypt) may also reject with a malformed error if the prior cert
+ # was issued by a different issuer / different CA. Retry without "replaces"
+ # whenever the failure mentions ARI or the replaces field.
+ if [ "$_replaces_certID" ] && { _contains "$response" "alreadyReplaced" || _contains "$response" "urn:ietf:params:acme:error:malformed" || _contains "$response" "'replaces'" || _contains "$response" "ARI"; }; then
+ _info "ARI 'replaces' rejected by CA, retrying newOrder without 'replaces'."
+ if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderObj}"; then
+ _err "Error creating new order."
+ _clearup
+ _on_issue_err "$_post_hook"
+ return 1
+ fi
+ fi
if _contains "$response" "invalid"; then
if echo "$response" | _normalizeJson | grep '"status":"invalid"' >/dev/null 2>&1; then
_err "Create new order with invalid status."
@@ -4740,7 +5303,7 @@ issue() {
#for dns manual mode
_savedomainconf "Le_OrderFinalize" "$Le_OrderFinalize"
- _authorizations_seg="$(echo "$response" | _json_decode | _egrep_o '"authorizations" *: *\[[^\[]*\]' | cut -d '[' -f 2 | tr -d ']' | tr -d '"')"
+ _authorizations_seg="$(echo "$response" | _json_decode | _authorizations_from_order)"
_debug2 _authorizations_seg "$_authorizations_seg"
if [ -z "$_authorizations_seg" ]; then
_err "_authorizations_seg not found."
@@ -4806,7 +5369,9 @@ $_authorizations_map"
vtype="$VTYPE_HTTP"
#todo, v2 wildcard force to use dns
- if _startswith "$_currentRoot" "$W_DNS"; then
+ if [ "$_currentRoot" = "$W_DNS_PERSIST" ]; then
+ vtype="$VTYPE_DNS_PERSIST"
+ elif _startswith "$_currentRoot" "$W_DNS"; then
vtype="$VTYPE_DNS"
fi
@@ -4849,7 +5414,7 @@ $_authorizations_map"
fi
# Fix for empty error objects in response which mess up the original code, adapted from fix suggested here: https://github.com/acmesh-official/acme.sh/issues/4933#issuecomment-1870499018
- entry="$(echo "$response" | sed s/'"error":{}'/'"error":null'/ | _egrep_o '[^\{]*"type":"'$vtype'"[^\}]*')"
+ entry="$(echo "$response" | sed s/'"error":{}'/'"error":null'/ | _egrep_o '[^{]*"type":"'$vtype'"[^}]*')"
_debug entry "$entry"
if [ -z "$keyauthorization" -a -z "$entry" ]; then
@@ -4864,18 +5429,7 @@ $_authorizations_map"
fi
if [ -z "$keyauthorization" ]; then
- token="$(echo "$entry" | _egrep_o '"token":"[^"]*' | cut -d : -f 2 | tr -d '"')"
- _debug token "$token"
-
- if [ -z "$token" ]; then
- _err "Cannot get domain token $entry"
- _clearup
- _on_issue_err "$_post_hook"
- return 1
- fi
-
uri="$(echo "$entry" | _egrep_o '"url":"[^"]*' | cut -d '"' -f 4 | _head_n 1)"
-
_debug uri "$uri"
if [ -z "$uri" ]; then
@@ -4884,8 +5438,26 @@ $_authorizations_map"
_on_issue_err "$_post_hook"
return 1
fi
- keyauthorization="$token.$thumbprint"
- _debug keyauthorization "$keyauthorization"
+
+ if [ "$vtype" = "$VTYPE_DNS_PERSIST" ]; then
+ # dns-persist-01 challenges have no token; the TXT record is
+ # provisioned out-of-band. Use a non-empty placeholder so the
+ # downstream code does not treat this entry as already verified.
+ keyauthorization="$VTYPE_DNS_PERSIST"
+ _debug keyauthorization "$keyauthorization"
+ else
+ token="$(echo "$entry" | _egrep_o '"token":"[^"]*' | cut -d : -f 2 | tr -d '"')"
+ _debug token "$token"
+
+ if [ -z "$token" ]; then
+ _err "Cannot get domain token $entry"
+ _clearup
+ _on_issue_err "$_post_hook"
+ return 1
+ fi
+ keyauthorization="$token.$thumbprint"
+ _debug keyauthorization "$keyauthorization"
+ fi
fi
dvlist="$d$sep$keyauthorization$sep$uri$sep$vtype$sep$_currentRoot$sep$_authz_url"
@@ -4921,6 +5493,8 @@ $_authorizations_map"
fi
_d_alias="$(_getfield "$_challenge_alias" "$_alias_index")"
test "$_d_alias" = "$NO_VALUE" && _d_alias=""
+ # strip the trailing dot of a fully-qualified alias domain
+ _d_alias="${_d_alias%.}"
_alias_index="$(_math "$_alias_index" + 1)"
_debug "_d_alias" "$_d_alias"
if [ "$_d_alias" ]; then
@@ -5115,6 +5689,8 @@ $_authorizations_map"
fi
fi
elif [ "$vtype" = "$VTYPE_ALPN" ]; then
+ _ncaddr="$(_getfield "$_local_addr" "$_ncIndex")"
+ _ncIndex="$(_math $_ncIndex + 1)"
acmevalidationv1="$(printf "%s" "$keyauthorization" | _digest "sha256" "hex")"
_debug acmevalidationv1 "$acmevalidationv1"
if ! _starttlsserver "$d" "" "$Le_TLSPort" "$keyauthorization" "$_ncaddr" "$acmevalidationv1"; then
@@ -5170,7 +5746,7 @@ $_authorizations_map"
status=$(echo "$response" | _egrep_o '"status":"[^"]*' | cut -d : -f 2 | tr -d '"')
_debug2 status "$status"
if _contains "$status" "invalid"; then
- error="$(echo "$response" | _egrep_o '"error":\{[^\}]*')"
+ error="$(echo "$response" | _egrep_o '"error":[{][^}]*')"
_debug2 error "$error"
errordetail="$(echo "$error" | _egrep_o '"detail": *"[^"]*' | cut -d '"' -f 4)"
_debug2 errordetail "$errordetail"
@@ -5345,7 +5921,14 @@ $_authorizations_map"
return 1
fi
- echo "$response" >"$CERT_PATH"
+ if ! _contains "$response" "$BEGIN_CERT"; then
+ response="$(echo "$response" | _dbase64 "multiline" | tr -d '\0' | _normalizeJson)"
+ _err "Signing failed: $(echo "$response" | _egrep_o '"detail":"[^"]*"')"
+ _on_issue_err "$_post_hook"
+ return 1
+ fi
+
+ echo "$response" | _strip_blank_lines >"$CERT_PATH"
_split_cert_chain "$CERT_PATH" "$CERT_FULLCHAIN_PATH" "$CA_CERT_PATH"
if [ -z "$_preferred_chain" ]; then
_preferred_chain=$(_readcaconf DEFAULT_PREFERRED_CHAIN)
@@ -5364,10 +5947,15 @@ $_authorizations_map"
_err "$response"
continue
fi
+
+ if ! _contains "$response" "$BEGIN_CERT"; then
+ _debug2 "Skipping alternate cert link due to unexpected response format."
+ continue
+ fi
_relcert="$CERT_PATH.alt"
_relfullchain="$CERT_FULLCHAIN_PATH.alt"
_relca="$CA_CERT_PATH.alt"
- echo "$response" >"$_relcert"
+ echo "$response" | _strip_blank_lines >"$_relcert"
_split_cert_chain "$_relcert" "$_relfullchain" "$_relca"
if [ "$DEBUG" ]; then
_debug "rel chain issuers: " "$(_get_chain_issuers "$_relfullchain")"
@@ -5427,7 +6015,7 @@ $_authorizations_map"
Le_CertCreateTimeStr=$(_time2str "$Le_CertCreateTime")
_savedomainconf "Le_CertCreateTimeStr" "$Le_CertCreateTimeStr"
- if [ -z "$Le_RenewalDays" ] || [ "$Le_RenewalDays" -lt "0" ]; then
+ if [ -z "$Le_RenewalDays" ]; then
Le_RenewalDays="$DEFAULT_RENEW"
else
_savedomainconf "Le_RenewalDays" "$Le_RenewalDays"
@@ -5451,12 +6039,17 @@ $_authorizations_map"
_clearaccountconf "HTTPS_INSECURE"
fi
- if [ "$Le_Listen_V4" ]; then
- _savedomainconf "Le_Listen_V4" "$Le_Listen_V4"
- _cleardomainconf Le_Listen_V6
- elif [ "$Le_Listen_V6" ]; then
- _savedomainconf "Le_Listen_V6" "$Le_Listen_V6"
- _cleardomainconf Le_Listen_V4
+ if [ "$Le_Listen_V4" ] || [ "$Le_Listen_V6" ]; then
+ if [ "$Le_Listen_V4" ]; then
+ _savedomainconf "Le_Listen_V4" "$Le_Listen_V4"
+ else
+ _cleardomainconf Le_Listen_V4
+ fi
+ if [ "$Le_Listen_V6" ]; then
+ _savedomainconf "Le_Listen_V6" "$Le_Listen_V6"
+ else
+ _cleardomainconf Le_Listen_V6
+ fi
fi
if [ "$Le_ForceNewDomainKey" = "1" ]; then
@@ -5472,25 +6065,75 @@ $_authorizations_map"
_info "It cannot be renewed automatically"
_info "See: $_VALIDITY_WIKI"
else
- _now=$(_time)
- _debug2 "_now" "$_now"
- _lifetime=$(_math $Le_NextRenewTime - $_now)
- _debug2 "_lifetime" "$_lifetime"
- if [ $_lifetime -gt 86400 ]; then
- #if lifetime is logner than one day, it will renew one day before
- Le_NextRenewTime=$(_math $Le_NextRenewTime - 86400)
- Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime")
- else
- #if lifetime is less than 24 hours, it will renew one hour before
- Le_NextRenewTime=$(_math $Le_NextRenewTime - 3600)
- Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime")
- fi
+ Le_NextRenewTime=$(_calc_validto_renew_time "$Le_NextRenewTime" "$Le_RenewalDays" "$(_time)")
+ Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime")
fi
+ elif [ "$Le_RenewalDays" -lt "0" ]; then
+ _enddate_value=$(_enddate "$CERT_PATH")
+ if [ "$?" != "0" ] || [ -z "$_enddate_value" ]; then
+ _err "Failed to get certificate end date for $CERT_PATH"
+ return 1
+ fi
+
+ _endtime=$(_ssldate2time "$_enddate_value")
+ if [ "$?" != "0" ] || [ -z "$_endtime" ]; then
+ _err "Cannot parse _enddate_value: $_enddate_value"
+ return 1
+ fi
+ Le_NextRenewTime=$(_math "$_endtime" + "$Le_RenewalDays" \* 24 \* 60 \* 60)
+ Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime")
else
- Le_NextRenewTime=$(_math "$Le_CertCreateTime" + "$Le_RenewalDays" \* 24 \* 60 \* 60)
- Le_NextRenewTime=$(_math "$Le_NextRenewTime" - 86400)
+ _endtime_for_cap=""
+ _enddate_value=$(_enddate "$CERT_PATH")
+ if [ "$?" = "0" ] && [ "$_enddate_value" ]; then
+ _endtime_for_cap=$(_ssldate2time "$_enddate_value")
+ fi
+ Le_NextRenewTime=$(_calc_next_renew_time "$Le_CertCreateTime" "$Le_RenewalDays" "$_endtime_for_cap")
Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime")
fi
+
+ # RFC 9773 ARI: if the CA exposes renewalInfo, override Le_NextRenewTime
+ # with a time picked at random within the suggestedWindow. This both gives
+ # the CA full control over renewal scheduling and disperses renewals across
+ # the network so all clients don't hit the CA at the same instant.
+ # Set NO_ARI=1 (env, account.conf, or ca.conf) to opt out and fall back to
+ # the legacy time-based renewal calculation.
+ if [ "$NO_ARI" = "1" ]; then
+ _debug "NO_ARI=1, skipping ARI suggestedWindow override"
+ elif [ "$ACME_RENEWAL_INFO" ] && [ -f "$CERT_PATH" ] && [ -z "$_notAfter" ]; then
+ _ari_resp_new="$(_get_ARI "$CERT_PATH")"
+ _debug2 "_ari_resp_new" "$_ari_resp_new"
+ _ari_start_new="$(echo "$_ari_resp_new" | _egrep_o '"start" *: *"[^"]*' | sed 's/.*"//')"
+ _ari_end_new="$(echo "$_ari_resp_new" | _egrep_o '"end" *: *"[^"]*' | sed 's/.*"//')"
+ if [ "$_ari_start_new" ] && [ "$_ari_end_new" ]; then
+ _ari_start_t_new="$(_date2time "$(echo "$_ari_start_new" | sed 's/\.[0-9]*//')")"
+ _ari_end_t_new="$(_date2time "$(echo "$_ari_end_new" | sed 's/\.[0-9]*//')")"
+ if [ "$_ari_start_t_new" ] && [ "$_ari_end_t_new" ] && [ "$_ari_end_t_new" -gt "$_ari_start_t_new" ]; then
+ _ari_window=$(_math "$_ari_end_t_new" - "$_ari_start_t_new")
+ _ari_offset=$(_math "$(_time)" % "$_ari_window")
+ Le_NextRenewTime=$(_math "$_ari_start_t_new" + "$_ari_offset")
+ Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime")
+ _info "ARI suggestedWindow: $(__green "$_ari_start_new") to $(__green "$_ari_end_new")"
+ _info "Next renewal time picked from ARI window: $(__green "$Le_NextRenewTimeStr")"
+ fi
+ fi
+ fi
+
+ # Warn when the scheduled renewal falls after the cert has already expired,
+ # e.g. a 1-day cert from an internal CA combined with the default 30-day
+ # schedule, which computes from the creation date and never looks at
+ # notAfter. Skip the warning for a fixed-date --valid-to: there
+ # Le_NextRenewTime equals the expiry by design and the non-renewable state
+ # was already reported above. https://github.com/acmesh-official/acme.sh/issues/6917
+ if [ -z "$_valid_to" ] || _startswith "$_valid_to" "+"; then
+ _renew_chk_enddate="$(_enddate "$CERT_PATH")"
+ _renew_chk_endtime="$(_ssldate2time "$_renew_chk_enddate")"
+ if [ "$Le_NextRenewTime" ] && [ "$_renew_chk_endtime" ] && [ "$Le_NextRenewTime" -ge "$_renew_chk_endtime" ]; then
+ _info "$(__red "WARNING: the cert expires at $_renew_chk_enddate, BEFORE the next scheduled renewal time $Le_NextRenewTimeStr.")"
+ _info "$(__red "The cert will already be expired when the renewal runs. If your CA issues short-lived certs, use a negative --days value (e.g. --days -1) to renew relative to the expiry time.")"
+ fi
+ fi
+
_savedomainconf "Le_NextRenewTimeStr" "$Le_NextRenewTimeStr"
_savedomainconf "Le_NextRenewTime" "$Le_NextRenewTime"
@@ -5500,6 +6143,12 @@ $_authorizations_map"
_toPkcs "$CERT_PFX_PATH" "$CERT_KEY_PATH" "$CERT_PATH" "$CA_CERT_PATH" "$Le_PFXPassword"
fi
+ #convert to pkcs8
+ Le_PKCS8Password="$(_readdomainconf Le_PKCS8Password)"
+ if [ "$Le_PKCS8Password" ]; then
+ _toPkcs8 "$CERT_PKCS8_PATH" "$CERT_KEY_PATH" "$Le_PKCS8Password"
+ fi
+
if [ "$_real_cert$_real_key$_real_ca$_reload_cmd$_real_fullchain" ]; then
_savedomainconf "Le_RealCertPath" "$_real_cert"
_savedomainconf "Le_RealCACertPath" "$_real_ca"
@@ -5518,6 +6167,18 @@ $_authorizations_map"
}
#in_out_cert out_fullchain out_ca
+#Reads a PEM chain from stdin, prints it without the blank lines.
+#Some CAs (Let's Encrypt) separate the certificates of a chain with a blank
+#line, others (ZeroSSL) don't. The blank lines are valid PEM (RFC 7468), but
+#some devices and APIs reject them, so the certs are stored back to back.
+#https://github.com/acmesh-official/acme.sh/issues/1940
+_strip_blank_lines() {
+ #spell out space and tab: Solaris sed treats [[:space:]] as a literal
+ #bracket set and silently stops matching the blank lines
+ _sbl_tab="$(printf '\t')"
+ sed "/^[ $_sbl_tab]*\$/d"
+}
+
_split_cert_chain() {
_certf="$1"
_fullchainf="$2"
@@ -5546,7 +6207,7 @@ renew() {
_debug "_renewServer" "$_renewServer"
_initpath "$Le_Domain" "$_isEcc"
-
+ _info "Renew: $Le_Domain"
_set_level=${NOTIFY_LEVEL:-$NOTIFY_LEVEL_DEFAULT}
_info "$(__green "Renewing: '$Le_Domain'")"
if [ ! -f "$DOMAIN_CONF" ]; then
@@ -5578,6 +6239,14 @@ renew() {
fi
_info "Renewing using Le_API=$Le_API"
+ # Honor --local-address given on the renew/renewAll command line: it overrides
+ # the value saved at issue time (and gets re-saved by issue() below), so certs
+ # issued before the machine gained multiple addresses can still be renewed.
+ # https://github.com/acmesh-official/acme.sh/issues/7009
+ if [ "$_local_address" ]; then
+ Le_LocalAddress="$_local_address"
+ fi
+
_clearAPI
_clearCA
export ACME_DIRECTORY="$Le_API"
@@ -5586,7 +6255,77 @@ renew() {
_debug2 "initpath again."
_initpath "$Le_Domain" "$_isEcc"
+ # ARI (RFC 9773): fetch the CA's suggestedWindow on every renewal check.
+ # If the window has started, renew now even if Le_NextRenewTime is in the future.
+ # Set NO_ARI=1 (env, account.conf, or ca.conf) to opt out and use only
+ # Le_NextRenewTime for the renewal decision.
+ if [ "$NO_ARI" = "1" ]; then
+ _debug "NO_ARI=1, skipping ARI suggestedWindow check"
+ elif [ -z "$FORCE" ] && [ -f "$CERT_PATH" ]; then
+ if _initAPI && [ "$ACME_RENEWAL_INFO" ]; then
+ _ari_resp="$(_get_ARI "$CERT_PATH")"
+ _debug2 "_ari_resp" "$_ari_resp"
+ _ari_start="$(echo "$_ari_resp" | _egrep_o '"start" *: *"[^"]*' | sed 's/.*"//')"
+ _ari_end="$(echo "$_ari_resp" | _egrep_o '"end" *: *"[^"]*' | sed 's/.*"//')"
+ _debug "ARI suggestedWindow.start" "$_ari_start"
+ _debug "ARI suggestedWindow.end" "$_ari_end"
+ if [ "$_ari_start" ] && [ "$_ari_end" ]; then
+ _ari_start_t="$(_date2time "$(echo "$_ari_start" | sed 's/\.[0-9]*//')")"
+ _ari_end_t="$(_date2time "$(echo "$_ari_end" | sed 's/\.[0-9]*//')")"
+ _ari_explanation_url="$(echo "$_ari_resp" | _egrep_o '"explanationURL" *: *"[^"]*' | sed 's/.*"//')"
+ _debug "_ari_start_t" "$_ari_start_t"
+ _debug "_ari_end_t" "$_ari_end_t"
+ _debug "_ari_explanation_url" "$_ari_explanation_url"
+ _debug "Le_NextRenewTime" "$Le_NextRenewTime"
+ # Update ARI if needed
+ if [ "$_ari_start_t" ] && [ "$_ari_end_t" ] && [ "$Le_NextRenewTime" ] && [ "$_ari_end_t" -gt "$_ari_start_t" ] && ([ "$Le_NextRenewTime" -lt "$_ari_start_t" ] || [ "$Le_NextRenewTime" -gt "$_ari_end_t" ]); then
+ _ari_old_time_str="$Le_NextRenewTimeStr"
+ _info "Current renewal time: $(__green "$_ari_old_time_str")"
+ _ari_window=$(_math "$_ari_end_t" - "$_ari_start_t")
+ _ari_offset=$(_math "$(_time)" % "$_ari_window")
+ Le_NextRenewTime=$(_math "$_ari_start_t" + "$_ari_offset")
+ Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime")
+ _info "ARI suggestedWindow: $(__green "$_ari_start") to $(__green "$_ari_end")"
+ _info "Updating renewal time picked from ARI window: $(__green "$Le_NextRenewTimeStr")"
+ _savedomainconf Le_NextRenewTime "$Le_NextRenewTime"
+ _savedomainconf Le_NextRenewTimeStr "$Le_NextRenewTimeStr"
+ fi
+ if [ "$Le_NextRenewTime" ] && [ "$(_time)" -ge "$Le_NextRenewTime" ]; then
+ _info "ARI suggested renewal has passed ($(__green "$Le_NextRenewTimeStr")), proceeding with renewal."
+ if [ "$_ari_explanation_url" ]; then
+ _info "For more information on this renewal: $(__green "$_ari_explanation_url")"
+ fi
+ fi
+ fi
+ fi
+ fi
+
if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then
+ _renew_retry_fixed=""
+ res="0"
+ _ensure_install "$Le_Domain"
+ res="$?"
+ if [ "$Le_DeployHook" ] && [ "$res" = "0" ]; then
+ _ensure_deploy "$Le_Domain"
+ res="$?"
+ fi
+ if [ "$res" != "0" ]; then
+ if [ -z "$_ACME_IN_RENEWALL" ]; then
+ if [ $_set_level -ge $NOTIFY_LEVEL_ERROR ]; then
+ _send_notify "Renew $Le_Domain error" "There is an error." "$NOTIFY_HOOK" 1
+ fi
+ fi
+ return 1
+ fi
+ if [ "$_renew_retry_fixed" ]; then
+ _info "Install/deploy retry succeeded, no renewal is needed."
+ if [ -z "$_ACME_IN_RENEWALL" ]; then
+ if [ $_set_level -ge $NOTIFY_LEVEL_RENEW ]; then
+ _send_notify "Renew $Le_Domain success" "Good, the cert install/deploy retry succeeded." "$NOTIFY_HOOK" 0
+ fi
+ fi
+ return 0
+ fi
_info "Skipping. Next renewal time is: $(__green "$Le_NextRenewTimeStr")"
_info "Add '$(__red '--force')' to force renewal."
if [ -z "$_ACME_IN_RENEWALL" ]; then
@@ -5629,11 +6368,8 @@ renew() {
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" "$Le_ExtKeyUse"
res="$?"
- if [ "$res" != "0" ]; then
- return "$res"
- fi
- if [ "$Le_DeployHook" ]; then
+ if [ "$Le_DeployHook" ] && [ "$res" = "0" ]; then
_deploy "$Le_Domain" "$Le_DeployHook"
res="$?"
fi
@@ -5673,6 +6409,10 @@ renewAll() {
_set_level=${NOTIFY_LEVEL:-$NOTIFY_LEVEL_DEFAULT}
_debug "_set_level" "$_set_level"
export _ACME_IN_RENEWALL=1
+ if ! [ -d "$CERT_HOME" ]; then
+ _err "$CERT_HOME is not a directory, please check your configuration."
+ return 1
+ fi
for di in "${CERT_HOME}"/*.* "${CERT_HOME}"/*:*; do
_debug di "$di"
if ! [ -d "$di" ]; then
@@ -5681,12 +6421,19 @@ renewAll() {
fi
d=$(basename "$di")
_debug d "$d"
+ _d_ari="$di.ari"
+ _debug _d_ari "$_d_ari"
(
if _endswith "$d" "$ECC_SUFFIX"; then
_isEcc=$(echo "$d" | cut -d "$ECC_SEP" -f 2)
d=$(echo "$d" | cut -d "$ECC_SEP" -f 1)
fi
renew "$d" "$_isEcc" "$_server"
+ rc="$?"
+ if [ "$rc" = "0" ] && [ "$_ari_explanation_url" ]; then
+ echo "$_ari_explanation_url" >"$_d_ari"
+ fi
+ return $rc
)
rc="$?"
_debug "Return code: $rc"
@@ -5701,8 +6448,13 @@ renewAll() {
_send_notify "Renew $d success" "Good, the cert is renewed." "$NOTIFY_HOOK" 0
fi
fi
+ _renewal_explanation=""
+ if [ -f "$_d_ari" ]; then
+ _renewal_explanation=" ($(cat "$_d_ari"))"
+ rm -f "$_d_ari"
+ fi
- _success_msg="${_success_msg} $d
+ _success_msg="${_success_msg} $d$_renewal_explanation
"
elif [ "$rc" = "$RENEW_SKIP" ]; then
if [ $_error_level -gt $NOTIFY_LEVEL_SKIP ]; then
@@ -5945,7 +6697,7 @@ list_profiles() {
fi
normalized_response=$(echo "$response" | _normalizeJson)
- profiles_json=$(echo "$normalized_response" | _egrep_o '"profiles" *: *\{[^\}]*\}')
+ profiles_json=$(echo "$normalized_response" | _egrep_o '"profiles" *: *[{][^}]*[}]')
if [ -z "$profiles_json" ]; then
_info "The CA '$_l_server_name' does not publish certificate profiles via its directory endpoint."
@@ -6007,6 +6759,45 @@ _deploy() {
_info "$(__green Success)"
fi
done
+
+ _deploy_success_time="$(_time)"
+ _savedomainconf "Le_DeploySuccessTime" "$_deploy_success_time"
+ _savedomainconf "Le_DeploySuccessTimeStr" "$(_time2str "$_deploy_success_time")"
+}
+
+_ensure_deploy() {
+ _d="$1"
+ if [ -z "$Le_DeployHook" ]; then
+ return 0
+ fi
+ if [ -z "$Le_CertCreateTime" ]; then
+ return 0
+ fi
+
+ _deploy_success_time="$(_readdomainconf Le_DeploySuccessTime)"
+ if [ -z "$_deploy_success_time" ]; then
+ _debug "Le_DeploySuccessTime is empty, skip deploy retry check."
+ return 0
+ fi
+ case "$_deploy_success_time$Le_CertCreateTime" in
+ *[!0-9]*)
+ _debug "Le_DeploySuccessTime or Le_CertCreateTime is not a number, skip deploy retry check."
+ return 0
+ ;;
+ esac
+
+ if [ "$_deploy_success_time" -lt "$Le_CertCreateTime" ]; then
+ _info "The cert was created after the last successful deploy, retrying deploy hooks."
+ if _deploy "$_d" "$Le_DeployHook"; then
+ _info "Deploy retry succeeded."
+ _renew_retry_fixed=1
+ return 0
+ fi
+ _err "Deploy retry failed."
+ return 1
+ fi
+
+ return 0
}
#domain hooks
@@ -6027,7 +6818,13 @@ deploy() {
fi
_debug2 DOMAIN_CONF "$DOMAIN_CONF"
- . "$DOMAIN_CONF"
+ # The cert dir may exist without a domain conf (e.g. the conf was deleted, or
+ # the cert was placed here manually). Deploy can still proceed using env-provided
+ # settings, and _savedomainconf below will recreate the conf, so only source it
+ # when present instead of failing on a missing file.
+ if [ -f "$DOMAIN_CONF" ]; then
+ . "$DOMAIN_CONF"
+ fi
_savedomainconf Le_DeployHook "$_hooks"
@@ -6160,9 +6957,54 @@ _installcert() {
_info "$(__green "Reload successful")"
else
_err "Reload error for: $_main_domain"
+ return 1
fi
fi
+ _installcert_success_time="$(_time)"
+ _savedomainconf "Le_InstallCertSuccessTime" "$_installcert_success_time"
+ _savedomainconf "Le_InstallCertSuccessTimeStr" "$(_time2str "$_installcert_success_time")"
+}
+
+_ensure_install() {
+ _d="$1"
+ if [ -z "$Le_CertCreateTime" ]; then
+ return 0
+ fi
+
+ _real_cert="$(_readdomainconf Le_RealCertPath)"
+ _real_key="$(_readdomainconf Le_RealKeyPath)"
+ _real_ca="$(_readdomainconf Le_RealCACertPath)"
+ _reload_cmd="$(_readdomainconf Le_ReloadCmd)"
+ _real_fullchain="$(_readdomainconf Le_RealFullChainPath)"
+ if [ -z "$_real_cert$_real_key$_real_ca$_reload_cmd$_real_fullchain" ]; then
+ return 0
+ fi
+
+ _installcert_success_time="$(_readdomainconf Le_InstallCertSuccessTime)"
+ if [ -z "$_installcert_success_time" ]; then
+ _debug "Le_InstallCertSuccessTime is empty, skip install retry check."
+ return 0
+ fi
+ case "$_installcert_success_time$Le_CertCreateTime" in
+ *[!0-9]*)
+ _debug "Le_InstallCertSuccessTime or Le_CertCreateTime is not a number, skip install retry check."
+ return 0
+ ;;
+ esac
+
+ if [ "$_installcert_success_time" -lt "$Le_CertCreateTime" ]; then
+ _info "The cert was created after the last successful install, retrying install cert."
+ if _installcert "$_d" "$_real_cert" "$_real_key" "$_real_ca" "$_real_fullchain" "$_reload_cmd"; then
+ _info "Install cert retry succeeded."
+ _renew_retry_fixed=1
+ return 0
+ fi
+ _err "Install cert retry failed."
+ return 1
+ fi
+
+ return 0
}
__read_password() {
@@ -6182,6 +7024,7 @@ _install_win_taskscheduler() {
_lesh="$1"
_centry="$2"
_randomminute="$3"
+ _randomhour="$4"
if ! _exists cygpath; then
_err "cygpath not found"
return 1
@@ -6208,8 +7051,10 @@ _install_win_taskscheduler() {
_info "$PROJECT_NAME will not save your password."
_info "Please input your Windows password for: $(__green "$_myname")"
_password="$(__read_password)"
- #SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_randomminute" '/RU' "$_myname" '/RP' "$_password" '/TR' "$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'" >/dev/null
- echo SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_randomminute" '/RU' "$_myname" '/RP' "$_password" '/TR' "\"$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'\"" | cmd.exe >/dev/null
+ #schtasks.exe /ST requires the HH:mm format, so the minute must be zero-padded (issue 4950)
+ _st_minute="$(printf "%02d" "$_randomminute")"
+ #SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_st_minute" '/RU' "$_myname" '/RP' "$_password" '/TR' "$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'" >/dev/null
+ echo SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_st_minute" '/RU' "$_myname" '/RP' "$_password" '/TR' "\"$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'\"" | cmd.exe >/dev/null
echo
}
@@ -6251,7 +7096,7 @@ installcronjob() {
fi
_t=$(_time)
random_minute=$(_math $_t % 60)
- random_hour=$(_math $_t / 60 % 24)
+ random_hour=$(_math $_t / 60 % 6)
if ! _exists "$_CRONTAB" && _exists "fcrontab"; then
_CRONTAB="fcrontab"
@@ -6260,7 +7105,7 @@ installcronjob() {
if ! _exists "$_CRONTAB"; then
if _exists cygpath && _exists schtasks.exe; then
_info "It seems you are on Windows, let's install the Windows scheduler task."
- if _install_win_taskscheduler "$lesh" "$_c_entry" "$random_minute"; then
+ if _install_win_taskscheduler "$lesh" "$_c_entry" "$random_minute" "$random_hour"; then
_info "Successfully installed Windows scheduler task."
return 0
else
@@ -6274,15 +7119,39 @@ installcronjob() {
return 1
fi
_info "Installing cron job"
- if ! $_CRONTAB -l | grep "$PROJECT_ENTRY --cron"; then
+ _cron_entry="$random_minute $random_hour,$(_math "$random_hour" + 6),$(_math "$random_hour" + 12),$(_math "$random_hour" + 18) * * * $lesh --cron --home \"$LE_WORKING_DIR\" $_c_entry> /dev/null"
+ _cron_entries="$($_CRONTAB -l 2>/dev/null)"
+ if [ "$?" != "0" ]; then
+ #when the user has no crontab yet, crontab -l also exits non-zero;
+ #only that case may proceed with an empty list. Any other listing
+ #failure must abort: piping an incomplete list back into 'crontab -'
+ #would wipe the user's existing cron jobs (issue 3079)
+ _cron_list_err="$($_CRONTAB -l 2>&1 >/dev/null)"
+ #separate greps: BRE alternation \| is a GNU extension and Solaris
+ #grep takes only a single -e pattern
+ if echo "$_cron_list_err" | grep -i "no crontab" >/dev/null ||
+ echo "$_cron_list_err" | grep -i "no fcrontab" >/dev/null ||
+ echo "$_cron_list_err" | grep -i "can't open" >/dev/null; then
+ _cron_entries=""
+ else
+ _err "Can not list the current cron jobs: $_cron_list_err"
+ _err "Refusing to install the cron job, that could wipe your existing cron jobs."
+ _err "Please add this cron job manually:"
+ _err "$_cron_entry"
+ return 1
+ fi
+ fi
+ if ! echo "$_cron_entries" | grep "$PROJECT_ENTRY --cron"; then
if _exists uname && uname -a | grep SunOS >/dev/null; then
_CRONTAB_STDIN="$_CRONTAB --"
else
_CRONTAB_STDIN="$_CRONTAB -"
fi
- $_CRONTAB -l | {
- cat
- echo "$random_minute $random_hour * * * $lesh --cron --home \"$LE_WORKING_DIR\" $_c_entry> /dev/null"
+ {
+ if [ "$_cron_entries" ]; then
+ echo "$_cron_entries"
+ fi
+ echo "$_cron_entry"
} | $_CRONTAB_STDIN
fi
if [ "$?" != "0" ]; then
@@ -6478,7 +7347,7 @@ _deactivate() {
_err "Cannot get new order for domain."
return 1
fi
- _authorizations_seg="$(echo "$response" | _egrep_o '"authorizations" *: *\[[^\]*\]' | cut -d '[' -f 2 | tr -d ']' | tr -d '"')"
+ _authorizations_seg="$(echo "$response" | _json_decode | _authorizations_from_order)"
_debug2 _authorizations_seg "$_authorizations_seg"
if [ -z "$_authorizations_seg" ]; then
_err "_authorizations_seg not found."
@@ -6511,7 +7380,7 @@ _deactivate() {
_debug "Trigger validation."
vtype="$(_getIdType "$_d_domain")"
# Fix for empty error objects in response which mess up the original code, adapted from fix suggested here: https://github.com/acmesh-official/acme.sh/issues/4933#issuecomment-1870499018
- entry="$(echo "$response" | sed s/'"error":{}'/'"error":null'/ | _egrep_o '[^\{]*"type":"'$vtype'"[^\}]*')"
+ entry="$(echo "$response" | sed s/'"error":{}'/'"error":null'/ | _egrep_o '[^{]*"type":"'$vtype'"[^}]*')"
_debug entry "$entry"
if [ -z "$entry" ]; then
_err "$d: Cannot get domain token"
@@ -6595,34 +7464,60 @@ deactivate() {
done
}
+#reads the output of "openssl x509 -text" from stdin, prints the hex AKI
+#the value is on the line right after the extension header; "grep -A" is not
+#portable (Solaris /usr/bin/grep: "illegal option -- A"), so select from the
+#header to EOF and keep the second line of that range
+_extractAKI() {
+ sed -n '/X509v3 Authority Key Identifier/,$p' | _head_n 2 | _tail_n 1 | tr -d ': ' | sed "s/keyid//"
+}
+
#cert
_getAKI() {
_cert="$1"
- openssl x509 -in "$_cert" -text -noout | grep "X509v3 Authority Key Identifier" -A 1 | _tail_n 1 | tr -d ' :'
+ ${ACME_OPENSSL_BIN:-openssl} x509 -in "$_cert" -text -noout | _extractAKI
}
#cert
_getSerial() {
_cert="$1"
- openssl x509 -in "$_cert" -serial -noout | cut -d = -f 2
+ ${ACME_OPENSSL_BIN:-openssl} x509 -in "$_cert" -serial -noout | cut -d = -f 2
}
#cert
-_get_ARI() {
+#Compute the ARI/replaces certID for a cert: base64url(AKI).base64url(Serial)
+#per RFC 9773 Section 4.1.
+_getARICertID() {
_cert="$1"
_aki=$(_getAKI "$_cert")
_ser=$(_getSerial "$_cert")
_debug2 "_aki" "$_aki"
_debug2 "_ser" "$_ser"
- _akiurl="$(echo "$_aki" | _h2b | _base64 | tr -d = | _url_encode)"
+ # RFC 9773 Section 4.1 requires the DER-encoded INTEGER value bytes of
+ # serialNumber. When the high bit of the first byte is set (>= 0x80) DER
+ # prepends a 0x00 sign byte to keep the integer positive; openssl's hex
+ # output strips that, so add it back. Boulder (LE) accepts either form,
+ # but Sectigo (ZeroSSL) is strict and rejects newOrder with HTTP 401
+ # "replaces field does not identify a certificate" if the byte is missing.
+ case "$_ser" in
+ [89aAbBcCdDeEfF]*) _ser="00$_ser" ;;
+ esac
+
+ _akiurl="$(echo "$_aki" | _h2b | _base64 | _url_replace)"
_debug2 "_akiurl" "$_akiurl"
- _serurl="$(echo "$_ser" | _h2b | _base64 | tr -d = | _url_encode)"
+ _serurl="$(echo "$_ser" | _h2b | _base64 | _url_replace)"
_debug2 "_serurl" "$_serurl"
- _ARI_URL="$ACME_RENEWAL_INFO/$_akiurl.$_serurl"
- _get "$_ARI_URL"
+ printf "%s.%s" "$_akiurl" "$_serurl"
+}
+#cert
+_get_ARI() {
+ _cert="$1"
+ _ari_certID="$(_getARICertID "$_cert")"
+ _ARI_URL="$ACME_RENEWAL_INFO/$_ari_certID"
+ _get "$_ARI_URL"
}
# Detect profile file if not specified as environment variable
@@ -6710,9 +7605,9 @@ _precheck() {
fi
if ! _exists "socat" && ! _exists "python" && ! _exists "python2" && ! _exists "python3"; then
- _err "It is recommended to install socat or python first."
- _err "We use socat or python for the standalone server, which is used for standalone mode."
- _err "If you don't want to use standalone mode, you may ignore this warning."
+ _info "It is recommended to install socat or python first."
+ _info "We use socat or python for the standalone server, which is used for standalone mode."
+ _info "If you don't want to use standalone mode, you may ignore this warning."
fi
return 0
@@ -6736,6 +7631,15 @@ _installalias() {
_c_home="$1"
_initpath
+ _alias_bin="$LE_WORKING_DIR/$PROJECT_ENTRY"
+ if [ ! -f "$_alias_bin" ]; then
+ #ACME_PACKAGED install: no copy in LE_WORKING_DIR, alias the current script
+ _script="$(_readlink "$_SCRIPT_")"
+ if [ -f "$_script" ]; then
+ _alias_bin="$_script"
+ fi
+ fi
+
_envfile="$LE_WORKING_DIR/$PROJECT_ENTRY.env"
if [ "$_upgrading" ] && [ "$_upgrading" = "1" ]; then
echo "$(cat "$_envfile")" | sed "s|^LE_WORKING_DIR.*$||" >"$_envfile"
@@ -6753,7 +7657,11 @@ _installalias() {
else
_sed_i "/^export LE_CONFIG_HOME/d" "$_envfile"
fi
- _setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\""
+ _setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$_alias_bin$_c_entry\""
+ if [ -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" ]; then
+ #the completion file does nothing when sourced by a non-bash shell
+ _setopt "$_envfile" ". \"$LE_WORKING_DIR/$PROJECT_ENTRY.completion\""
+ fi
_profile="$(_detect_profile)"
if [ "$_profile" ]; then
@@ -6776,7 +7684,7 @@ _installalias() {
else
_sed_i "/^setenv LE_CONFIG_HOME/d" "$_cshfile"
fi
- _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\""
+ _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$_alias_bin$_c_entry\""
_setopt "$_csh_profile" "source \"$_cshfile\""
fi
@@ -6788,7 +7696,7 @@ _installalias() {
if [ "$_c_home" ]; then
_setopt "$_cshfile" "setenv LE_CONFIG_HOME" " " "\"$LE_CONFIG_HOME\""
fi
- _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\""
+ _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$_alias_bin$_c_entry\""
_setopt "$_tcsh_profile" "source \"$_cshfile\""
fi
@@ -6862,25 +7770,38 @@ install() {
chmod 700 "$LE_CONFIG_HOME"
fi
- cp "$PROJECT_ENTRY" "$LE_WORKING_DIR/" && chmod +x "$LE_WORKING_DIR/$PROJECT_ENTRY"
+ if [ "$ACME_PACKAGED" ]; then
+ #the script and its hooks are managed by a system package manager,
+ #do not copy them into LE_WORKING_DIR. https://github.com/acmesh-official/acme.sh/issues/7135
+ _info "ACME_PACKAGED is set, skipping the script copy."
+ else
+ cp "$PROJECT_ENTRY" "$LE_WORKING_DIR/" && chmod +x "$LE_WORKING_DIR/$PROJECT_ENTRY"
- if [ "$?" != "0" ]; then
- _err "Installation failed, cannot copy $PROJECT_ENTRY"
- return 1
+ if [ "$?" != "0" ]; then
+ _err "Installation failed, cannot copy $PROJECT_ENTRY"
+ return 1
+ fi
+
+ _info "Installed to $LE_WORKING_DIR/$PROJECT_ENTRY"
+
+ if [ -f "$PROJECT_ENTRY.completion" ]; then
+ cp "$PROJECT_ENTRY.completion" "$LE_WORKING_DIR/"
+ _debug "Installed bash completion to $LE_WORKING_DIR/$PROJECT_ENTRY.completion"
+ fi
fi
- _info "Installed to $LE_WORKING_DIR/$PROJECT_ENTRY"
-
if [ "$_ACME_IN_CRON" != "1" ] && [ -z "$_noprofile" ]; then
_installalias "$_c_home"
fi
- for subf in $_SUB_FOLDERS; do
- if [ -d "$subf" ]; then
- mkdir -p "$LE_WORKING_DIR/$subf"
- cp "$subf"/* "$LE_WORKING_DIR"/"$subf"/
- fi
- done
+ if [ -z "$ACME_PACKAGED" ]; then
+ for subf in $_SUB_FOLDERS; do
+ if [ -d "$subf" ]; then
+ mkdir -p "$LE_WORKING_DIR/$subf"
+ cp "$subf"/* "$LE_WORKING_DIR"/"$subf"/
+ fi
+ done
+ fi
if [ ! -f "$ACCOUNT_CONF_PATH" ]; then
_initconf
@@ -6892,6 +7813,12 @@ install() {
if [ "$_DEFAULT_CERT_HOME" != "$CERT_HOME" ]; then
_saveaccountconf "CERT_HOME" "$CERT_HOME"
+ # Create the custom cert home now instead of on first issuance, so the
+ # user can see --install honored it.
+ # https://github.com/acmesh-official/acme.sh/issues/4756
+ if [ ! -d "$CERT_HOME" ]; then
+ mkdir -p "$CERT_HOME"
+ fi
fi
if [ "$_DEFAULT_ACCOUNT_KEY_PATH" != "$ACCOUNT_KEY_PATH" ]; then
@@ -6902,7 +7829,7 @@ install() {
installcronjob "$_c_home"
fi
- if [ -z "$NO_DETECT_SH" ]; then
+ if [ -z "$NO_DETECT_SH" ] && [ -z "$ACME_PACKAGED" ]; then
#Modify shebang
if _exists bash; then
_bash_path="$(bash -c "command -v bash 2>/dev/null")"
@@ -6927,7 +7854,9 @@ install() {
if [ "$_accountemail" ]; then
_saveaccountconf "ACCOUNT_EMAIL" "$_accountemail"
fi
- _saveaccountconf "UPGRADE_HASH" "$(_getUpgradeHash)"
+ if [ -z "$ACME_PACKAGED" ]; then
+ _saveaccountconf "UPGRADE_HASH" "$(_getUpgradeHash)"
+ fi
_info OK
}
@@ -6941,7 +7870,12 @@ uninstall() {
_uninstallalias
- rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY"
+ if [ -z "$ACME_PACKAGED" ]; then
+ #don't remove the script when it is managed by a system package manager,
+ #LE_WORKING_DIR may point to the packaged files
+ rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY"
+ rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion"
+ fi
_info "The keys and certs are in \"$(__green "$LE_CONFIG_HOME")\". You can remove them by yourself."
}
@@ -6977,20 +7911,24 @@ cron() {
_initpath
_info "$(__green "===Starting cron===")"
if [ "$AUTO_UPGRADE" = "1" ]; then
- export LE_WORKING_DIR
- (
- if ! upgrade; then
- _err "Cron: Upgrade failed!"
- return 1
+ if [ "$ACME_PACKAGED" ]; then
+ _info "ACME_PACKAGED is set, skipping the auto upgrade."
+ else
+ export LE_WORKING_DIR
+ (
+ if ! upgrade; then
+ _err "Cron: Upgrade failed!"
+ return 1
+ fi
+ )
+ . "$LE_WORKING_DIR/$PROJECT_ENTRY" >/dev/null
+
+ if [ -t 1 ]; then
+ __INTERACTIVE="1"
fi
- )
- . "$LE_WORKING_DIR/$PROJECT_ENTRY" >/dev/null
- if [ -t 1 ]; then
- __INTERACTIVE="1"
+ _info "Automatically upgraded to: $VER"
fi
-
- _info "Automatically upgraded to: $VER"
fi
_TREAT_SKIP_AS_SUCCESS="1"
renewAll
@@ -7039,6 +7977,14 @@ _send_notify() {
continue
fi
if ! (
+ # The dns/deploy hooks export _H1.._H5 in the main process, so the
+ # values are inherited here. Clear them: a stale Authorization header
+ # from another service must not leak into the notify request.
+ export _H1=""
+ export _H2=""
+ export _H3=""
+ export _H4=""
+ export _H5=""
if ! . "$_n_hook_file"; then
_err "Error loading file $_n_hook_file. Please check your API file and try again."
return 1
@@ -7156,8 +8102,11 @@ Commands:
-ccr, --create-csr Create CSR, professional use.
--create-domain-key Create an domain private key, professional use.
--update-account Update account info.
+ --update-account-key Rotate account key.
--register-account Register account key.
--deactivate-account Deactivate the account.
+ --make-dns-persist-value Print the DNS TXT record(s) to enable persistent DNS validation
+ (draft-ietf-acme-dns-persist-01). Use with -d .
--create-account-key Create an account private key, professional use.
--install-cronjob Install the cron job to renew certs, you don't need to call this. The 'install' command can automatically install the cron job.
--uninstall-cronjob Uninstall the cron job. The 'uninstall' command can do this automatically.
@@ -7205,16 +8154,34 @@ Parameters:
--dns [dns_hook] Use dns manual mode or dns api. Defaults to manual mode when argument is omitted.
See: $_DNS_API_WIKI
+ --dns-persist Use dns-persist-01 validation (draft-ietf-acme-dns-persist-01).
+ Requires the persistent _validation-persist TXT record to already
+ exist. Use '--make-dns-persist-value' to print the value to add.
+
--dnssleep The time in seconds to wait for all the txt records to propagate in dns api mode.
It's not necessary to use this by default, $PROJECT_NAME polls dns status by DOH automatically.
- -k, --keylength Specifies the domain key length: 2048, 3072, 4096, 8192 or ec-256, ec-384, ec-521.
- -ak, --accountkeylength Specifies the account key length: 2048, 3072, 4096
+ -k, --keylength Specifies the domain key length: 2048, 3072, 4096, 8192 or ec-256 (default), ec-384, ec-521.
+ -ak, --accountkeylength Specifies the account key length: 2048, 3072, 4096, 8192 or ec-256 (default), ec-384, ec-521.
--log [file] Specifies the log file. Defaults to \"$DEFAULT_LOG_FILE\" if argument is omitted.
--log-level <1|2> Specifies the log level, default is $DEFAULT_LOG_LEVEL.
--syslog <0|3|6|7> Syslog level, 0: disable syslog, 3: error, 6: info, 7: debug.
--eab-kid Key Identifier for External Account Binding.
--eab-hmac-key HMAC key for External Account Binding.
+ --dns-persist-wildcard Used with '--make-dns-persist-value'. Adds 'policy=wildcard' to the
+ generated TXT record so the issuer is also authorized for wildcards
+ and subdomains (draft-ietf-acme-dns-persist-01). It is implied when
+ the domain given to -d is a wildcard (e.g. '*.example.com'); the
+ record itself is always published at the base domain.
+ --dns-persist-ca-name Used with '--make-dns-persist-value'. Use the given CA identity domain
+ (e.g. 'ssl.com') as the issuer-domain-name in the TXT record. If
+ omitted, the identities are read from the ACME directory's
+ 'caaIdentities' field and one record is printed per identity.
+ --dns-persist-days Used with '--make-dns-persist-value'. Add a 'persistUntil' field to
+ the TXT record so the record self-expires N days from now (the CA
+ will refuse new validations against the record after that time).
+ If omitted, the record has no expiry.
+
These parameters are to install the cert to nginx/Apache or any other server after issue/renew a cert:
@@ -7233,8 +8200,11 @@ Parameters:
--config-home Specifies the home dir to save all the configurations.
--useragent Specifies the user agent string. it will be saved for future use too.
-m, --email Specifies the account email, only valid for the '--install' and '--update-account' command.
+ Multiple emails can be given as a comma-separated list: 'a@example.com,b@example.com'
--accountkey Specifies the account key path, only valid for the '--install' command.
--days Specifies the days to renew the cert when using '--issue' command. The default value is $DEFAULT_RENEW days.
+ A negative value renews that many days before the cert expiry.
+ Negative values could be used to specify a number of days relative to the expiration date of the certificate.
--httpport Specifies the standalone listening port. Only valid if the server is behind a reverse proxy or load balancer.
--tlsport Specifies the standalone tls listening port. Only valid if the server is behind a reverse proxy or load balancer.
--local-address Specifies the standalone/tls server listening address, in case you have multiple ip addresses.
@@ -7260,8 +8230,9 @@ Parameters:
--ocsp, --ocsp-must-staple Generate OCSP-Must-Staple extension.
--always-force-new-domain-key Generate new domain key on renewal. Otherwise, the domain key is not changed by default.
--auto-upgrade [0|1] Valid for '--upgrade' command, indicating whether to upgrade automatically in future. Defaults to 1 if argument is omitted.
- --listen-v4 Force standalone/tls server to listen at ipv4.
- --listen-v6 Force standalone/tls server to listen at ipv6.
+ --listen-v4 Force standalone/tls server to listen at ipv4 only.
+ By default the standalone server listens on both ipv4 and ipv6.
+ --listen-v6 Force standalone/tls server to listen at ipv6 only.
--request-v4 Force client requests to use ipv4 to connect to the CA server.
--request-v6 Force client requests to use ipv6 to connect to the CA server.
--openssl-bin Specifies a custom openssl bin location.
@@ -7283,13 +8254,17 @@ Parameters:
--revoke-reason <0-10> The reason for revocation, can be used in conjunction with the '--revoke' command.
See: $_REVOKE_WIKI
- --password Add a password to exported pfx file. Use with --to-pkcs12.
+ --password Add a password to the exported pfx or pkcs8 file. Use with '--to-pkcs12' or '--to-pkcs8'.
"
}
installOnline() {
+ if [ "$ACME_PACKAGED" ]; then
+ _err "ACME_PACKAGED is set: acme.sh is managed by the system package manager, please use it to upgrade."
+ return 1
+ fi
_info "Installing from online archive."
_branch="$BRANCH"
@@ -7313,7 +8288,9 @@ installOnline() {
cd "$PROJECT_NAME-$_branch"
chmod +x $PROJECT_ENTRY
- if ./$PROJECT_ENTRY --install "$@"; then
+ ./$PROJECT_ENTRY --install "$@"
+ _install_rc="$?"
+ if [ "$_install_rc" = "0" ]; then
_info "Install success!"
fi
@@ -7321,6 +8298,9 @@ installOnline() {
rm -rf "$PROJECT_NAME-$_branch"
rm -f "$localname"
+ # Propagate the install result so a failed upgrade is not reported as
+ # success. https://github.com/acmesh-official/acme.sh/issues/6477
+ exit "$_install_rc"
)
}
@@ -7342,6 +8322,10 @@ _getUpgradeHash() {
}
upgrade() {
+ if [ "$ACME_PACKAGED" ]; then
+ _err "ACME_PACKAGED is set: acme.sh is managed by the system package manager, please use it to upgrade."
+ exit 1
+ fi
if (
_initpath
[ -z "$FORCE" ] && [ "$(_getUpgradeHash)" = "$(_readaccountconf "UPGRADE_HASH")" ] && _info "Already up to date!" && exit 0
@@ -7413,9 +8397,16 @@ _checkSudo() {
return 0
fi
if [ -n "$SUDO_COMMAND" ]; then
- #it's a normal user doing "sudo su", or `sudo -i` or `sudo -s`, or `sudo su acmeuser1`
- _endswith "$SUDO_COMMAND" /bin/su || _contains "$SUDO_COMMAND" "/bin/su " || grep "^$SUDO_COMMAND\$" /etc/shells >/dev/null 2>&1
- return $?
+ #The SUDO_* env vars are often inherited into shells that were not
+ #started as `sudo acme.sh` at all (e.g. `sudo su - user`, or
+ #`sudo pct enter ` on Proxmox, which copies them into the
+ #container). Only warn when sudo was used to run acme.sh itself;
+ #anything else means the sudo happened further up and is fine.
+ #https://github.com/acmesh-official/acme.sh/issues/6400
+ if _contains "$SUDO_COMMAND" "$PROJECT_ENTRY"; then
+ return 1
+ fi
+ return 0
fi
#otherwise
return 1
@@ -7585,6 +8576,9 @@ _process() {
_valid_to=""
_certificate_profile=""
_extended_key_usage=""
+ _dns_persist_wildcard=""
+ _dns_persist_ca_name=""
+ _dns_persist_days=""
while [ ${#} -gt 0 ]; do
case "${1}" in
@@ -7673,12 +8667,29 @@ _process() {
--update-account | --updateaccount)
_CMD="updateaccount"
;;
+ --update-account-key | --updateaccountkey)
+ _CMD="updateaccountkey"
+ ;;
--register-account | --registeraccount)
_CMD="registeraccount"
;;
--deactivate-account)
_CMD="deactivateaccount"
;;
+ --make-dns-persist-value | --makednspersistvalue)
+ _CMD="makednspersistvalue"
+ ;;
+ --dns-persist-wildcard | --dnspersistwildcard)
+ _dns_persist_wildcard="1"
+ ;;
+ --dns-persist-ca-name | --dnspersistcaname)
+ _dns_persist_ca_name="$2"
+ shift
+ ;;
+ --dns-persist-days | --dnspersistdays)
+ _dns_persist_days="$2"
+ shift
+ ;;
--set-notify)
_CMD="setnotify"
;;
@@ -7822,6 +8833,14 @@ _process() {
_webroot="$_webroot,$wvalue"
fi
;;
+ --dns-persist)
+ wvalue="$W_DNS_PERSIST"
+ if [ -z "$_webroot" ]; then
+ _webroot="$wvalue"
+ else
+ _webroot="$_webroot,$wvalue"
+ fi
+ ;;
--dnssleep)
_dnssleep="$2"
Le_DNSSleep="$_dnssleep"
@@ -8188,6 +9207,22 @@ _process() {
_debug2 LE_WORKING_DIR "$LE_WORKING_DIR"
+ # --valid-to pins the cert lifetime, so a creation-anchored (positive)
+ # --days schedule can not apply and is rejected. A negative --days is
+ # anchored to the expiry and composes with a relative --valid-to: the
+ # cert renews that many days before the expiry.
+ if [ "$_days" ] && [ "$_valid_to" ]; then
+ if ! _startswith "$_valid_to" "+"; then
+ _err "--days can not be used together with a fixed-date --valid-to: such a cert can not be renewed automatically."
+ return 1
+ fi
+ if ! _startswith "$_days" "-"; then
+ _err "A positive --days can not be used together with --valid-to, the renewal time is derived from the expiry time."
+ _err "Use a negative --days to renew that many days before the expiry, or omit --days to renew 1 day before the expiry."
+ return 1
+ fi
+ fi
+
if [ "$DEBUG" ]; then
version
if [ "$_server" ]; then
@@ -8235,9 +9270,15 @@ _process() {
updateaccount)
updateaccount
;;
+ updateaccountkey)
+ updateaccountkey "$_accountkeylength"
+ ;;
deactivateaccount)
deactivateaccount
;;
+ makednspersistvalue)
+ makednspersistvalue "$_domain" "$_dns_persist_wildcard" "$_dns_persist_ca_name" "$_dns_persist_days"
+ ;;
list)
list "$_listraw" "$_domain"
;;
@@ -8251,7 +9292,7 @@ _process() {
toPkcs "$_domain" "$_password" "$_ecc"
;;
toPkcs8)
- toPkcs8 "$_domain" "$_ecc"
+ toPkcs8 "$_domain" "$_password" "$_ecc"
;;
createAccountKey)
createAccountKey "$_accountkeylength"
diff --git a/acme.sh.completion b/acme.sh.completion
new file mode 100644
index 00000000..26cb88da
--- /dev/null
+++ b/acme.sh.completion
@@ -0,0 +1,341 @@
+# Bash completion for acme.sh: https://github.com/acmesh-official/acme.sh
+#
+# "acme.sh --install" copies this file to the acme.sh home dir and wires
+# it into acme.sh.env, so the completion is loaded automatically in new
+# bash sessions after installation.
+#
+# To use it without installing acme.sh, source it from ~/.bashrc, or copy
+# it to /usr/share/bash-completion/completions/acme.sh
+#
+# Zsh users can load it with:
+# autoload -U +X bashcompinit && bashcompinit
+# . /path/to/acme.sh.completion
+
+# This file may also be sourced by non-bash shells via acme.sh.env,
+# so silently do nothing if the "complete" builtin is not available.
+if ! command -v complete >/dev/null 2>&1; then
+ return 0 2>/dev/null || exit 0
+fi
+
+# Add each word of $1 that starts with $cur to COMPREPLY.
+# The words are read line by line, so that candidates like a wildcard
+# domain "*.example.com" are never glob-expanded against the cwd.
+_acme_sh_add_matches() {
+ local _word
+ while read -r _word; do
+ [ -n "$_word" ] || continue
+ case "$_word" in
+ "$cur"*) COMPREPLY=("${COMPREPLY[@]}" "$_word") ;;
+ esac
+ done </dev/null 2>&1; then
+ compopt -o filenames 2>/dev/null
+ fi
+ return 0
+}
+
+_acme_sh_dirs() {
+ local _dir
+ while IFS= read -r _dir; do
+ [ -n "$_dir" ] || continue
+ COMPREPLY=("${COMPREPLY[@]}" "$_dir")
+ done </dev/null 2>&1; then
+ compopt -o filenames 2>/dev/null
+ fi
+ return 0
+}
+
+# Complete the domains that already have a cert: every directory in the
+# config home that contains a ".conf" file ("_ecc" suffix stripped).
+_acme_sh_domains() {
+ local _dir _name _domains=""
+ [ -n "${ZSH_VERSION:-}" ] && setopt localoptions nonomatch 2>/dev/null
+ for _dir in "$_acme_conf_home"/*/; do
+ [ -d "$_dir" ] || continue
+ _name="${_dir%/}"
+ _name="${_name##*/}"
+ _name="${_name%_ecc}"
+ if [ -f "${_dir}${_name}.conf" ]; then
+ case " $_domains " in
+ *" $_name "*) ;;
+ *) _domains="$_domains $_name" ;;
+ esac
+ fi
+ done
+ _acme_sh_add_matches "$_domains"
+}
+
+# Complete hook names from a subfolder of the acme.sh home dir.
+# $1: subfolder (dnsapi/deploy/notify), $2: file name prefix or empty.
+_acme_sh_hooks() {
+ local _file _hooks=""
+ [ -n "${ZSH_VERSION:-}" ] && setopt localoptions nonomatch 2>/dev/null
+ for _file in "$_acme_home/$1/$2"*.sh; do
+ [ -f "$_file" ] || continue
+ _file="${_file##*/}"
+ _hooks="$_hooks ${_file%.sh}"
+ done
+ _acme_sh_add_matches "$_hooks"
+}
+
+_acme_sh_completion() {
+ local cur prev _acme_home _acme_conf_home
+ COMPREPLY=()
+ cur="${COMP_WORDS[COMP_CWORD]}"
+ prev=""
+ if [ "$COMP_CWORD" -gt 0 ]; then
+ prev="${COMP_WORDS[COMP_CWORD - 1]}"
+ fi
+ _acme_home="${LE_WORKING_DIR:-$HOME/.acme.sh}"
+ _acme_conf_home="${LE_CONFIG_HOME:-$_acme_home}"
+
+ # The first argument is the command.
+ if [ "$COMP_CWORD" -eq 1 ]; then
+ _acme_sh_add_matches "
+ --help
+ --version
+ --install
+ --install-online
+ --uninstall
+ --upgrade
+ --issue
+ --deploy
+ --sign-csr
+ --show-csr
+ --install-cert
+ --renew
+ --renew-all
+ --revoke
+ --remove
+ --list
+ --list-profiles
+ --info
+ --to-pkcs12
+ --to-pkcs8
+ --create-account-key
+ --create-domain-key
+ --create-csr
+ --deactivate
+ --update-account
+ --register-account
+ --deactivate-account
+ --make-dns-persist-value
+ --install-cronjob
+ --uninstall-cronjob
+ --cron
+ --set-notify
+ --set-default-ca
+ --set-default-chain
+ "
+ return 0
+ fi
+
+ # Complete the value of the previous option.
+ case "$prev" in
+ -d | --domain | --challenge-alias | --domain-alias)
+ _acme_sh_domains
+ return 0
+ ;;
+ --dns)
+ # The dns hook argument is optional, keep completing options if the
+ # current word already looks like one.
+ case "$cur" in
+ -*) ;;
+ *)
+ _acme_sh_hooks "dnsapi" "dns_"
+ return 0
+ ;;
+ esac
+ ;;
+ --deploy-hook)
+ _acme_sh_hooks "deploy" ""
+ return 0
+ ;;
+ --notify-hook)
+ _acme_sh_hooks "notify" ""
+ return 0
+ ;;
+ --server)
+ _acme_sh_add_matches "letsencrypt letsencrypt_test zerossl sslcom google google_test actalis"
+ return 0
+ ;;
+ -k | --keylength | -ak | --accountkeylength)
+ _acme_sh_add_matches "2048 3072 4096 8192 ec-256 ec-384 ec-521"
+ return 0
+ ;;
+ --debug)
+ # Optional argument.
+ case "$cur" in
+ -*) ;;
+ *)
+ _acme_sh_add_matches "0 1 2 3"
+ return 0
+ ;;
+ esac
+ ;;
+ --log)
+ # Optional argument.
+ case "$cur" in
+ -*) ;;
+ *)
+ _acme_sh_files
+ return 0
+ ;;
+ esac
+ ;;
+ --nginx)
+ # Optional argument.
+ case "$cur" in
+ -*) ;;
+ *)
+ _acme_sh_files
+ return 0
+ ;;
+ esac
+ ;;
+ --auto-upgrade | --always-force-new-domain-key)
+ # Optional argument.
+ case "$cur" in
+ -*) ;;
+ *)
+ _acme_sh_add_matches "0 1"
+ return 0
+ ;;
+ esac
+ ;;
+ --log-level)
+ _acme_sh_add_matches "1 2"
+ return 0
+ ;;
+ --syslog)
+ _acme_sh_add_matches "0 3 6 7"
+ return 0
+ ;;
+ --notify-level)
+ _acme_sh_add_matches "0 1 2 3"
+ return 0
+ ;;
+ --notify-mode)
+ _acme_sh_add_matches "0 1"
+ return 0
+ ;;
+ --revoke-reason)
+ _acme_sh_add_matches "0 1 2 3 4 5 6 7 8 9 10"
+ return 0
+ ;;
+ --cert-file | --key-file | --ca-file | --fullchain-file | --csr | --accountconf | --accountkey | --ca-bundle | --openssl-bin)
+ _acme_sh_files
+ return 0
+ ;;
+ -w | --webroot | --home | --cert-home | --config-home | --ca-path)
+ _acme_sh_dirs
+ return 0
+ ;;
+ -m | --email | --password | --useragent | --days | --valid-from | --valid-to | --httpport | --tlsport | --local-address | --dnssleep | --pre-hook | --post-hook | --renew-hook | --reloadcmd | --extended-key-usage | -b | --branch | --notify-source | --eab-kid | --eab-hmac-key | --preferred-chain | --cert-profile | --certificate-profile | --dns-persist-ca-name | --dns-persist-days)
+ # These options take a free-form value, offer nothing.
+ return 0
+ ;;
+ esac
+
+ # Complete the parameters.
+ _acme_sh_add_matches "
+ --accountconf
+ --accountkey
+ --accountkeylength
+ --alpn
+ --always-force-new-domain-key
+ --apache
+ --auto-upgrade
+ --branch
+ --ca-bundle
+ --ca-file
+ --ca-path
+ --cert-file
+ --cert-home
+ --cert-profile
+ --challenge-alias
+ --config-home
+ --csr
+ --days
+ --debug
+ --deploy-hook
+ --dns
+ --dns-persist
+ --dns-persist-ca-name
+ --dns-persist-days
+ --dns-persist-wildcard
+ --dnssleep
+ --domain
+ --domain-alias
+ --eab-hmac-key
+ --eab-kid
+ --ecc
+ --email
+ --extended-key-usage
+ --force
+ --force-color
+ --fullchain-file
+ --home
+ --httpport
+ --insecure
+ --key-file
+ --keylength
+ --listen-v4
+ --listen-v6
+ --listraw
+ --local-address
+ --log
+ --log-level
+ --nginx
+ --no-color
+ --no-cron
+ --no-profile
+ --notify-hook
+ --notify-level
+ --notify-mode
+ --notify-source
+ --ocsp-must-staple
+ --openssl-bin
+ --output-insecure
+ --password
+ --post-hook
+ --pre-hook
+ --preferred-chain
+ --reloadcmd
+ --renew-hook
+ --revoke-reason
+ --server
+ --staging
+ --standalone
+ --stateless
+ --stop-renew-on-error
+ --syslog
+ --tlsport
+ --treat-skip-as-success
+ --use-wget
+ --useragent
+ --valid-from
+ --valid-to
+ --webroot
+ --yes-I-know-dns-manual-mode-enough-go-ahead-please
+ "
+ return 0
+}
+
+complete -F _acme_sh_completion acme.sh
diff --git a/deploy/baidu_cdn.sh b/deploy/baidu_cdn.sh
new file mode 100644
index 00000000..7fe31f9b
--- /dev/null
+++ b/deploy/baidu_cdn.sh
@@ -0,0 +1,222 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034,SC2154
+
+# Deploy hook: Baidu Cloud CDN
+#
+# Code generated by GitHub Copilot with Claude Sonnet 4.6 and OpenAI Codex with GPT-5.6 Sol
+#
+# API Doc: https://cloud.baidu.com/doc/CDN/s/Zkna2r57w
+#
+# Uses the same credential variables as dnsapi/dns_baidu.sh:
+# export Baidu_AK="your-access-key-id"
+# export Baidu_SK="your-secret-access-key"
+#
+# To deploy to a CDN domain different from the certificate CN
+# (e.g. wildcard or multi-domain certs):
+# export DEPLOY_BAIDU_CDN_DOMAIN="cdn.example.com"
+#
+# Multiple CDN domains sharing the same certificate:
+# export DEPLOY_BAIDU_CDN_DOMAIN="cdn1.example.com cdn2.example.com"
+
+BAIDU_CDN_HOST="cdn.baidubce.com"
+_BAIDU_CDN_BCE_AUTH_RESULT=""
+
+baidu_cdn_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 ! _baidu_cdn_load_credentials; then
+ return 1
+ fi
+
+ _getdeployconf DEPLOY_BAIDU_CDN_DOMAIN
+ if [ "$DEPLOY_BAIDU_CDN_DOMAIN" ]; then
+ _savedeployconf DEPLOY_BAIDU_CDN_DOMAIN "$DEPLOY_BAIDU_CDN_DOMAIN"
+ else
+ DEPLOY_BAIDU_CDN_DOMAIN="$_cdomain"
+ fi
+
+ # Build JSON "domains" array from space-separated domain list
+ _domains_json=""
+ for _d in $DEPLOY_BAIDU_CDN_DOMAIN; do
+ _d_e="$(_baidu_cdn_json_escape "$_d")"
+ if [ -z "$_domains_json" ]; then
+ _domains_json="\"${_d_e}\""
+ else
+ _domains_json="${_domains_json},\"${_d_e}\""
+ fi
+ done
+
+ # Build a valid cert name: must start with a letter, allow [A-Za-z0-9-/.], max 65 chars
+ _cert_name="$(printf "%s" "$_cdomain" | sed 's/\*\./wildcard./g;s/[^A-Za-z0-9./]/-/g' | cut -c 1-65)"
+ case "$_cert_name" in
+ [A-Za-z]*) ;;
+ *) _cert_name="c${_cert_name}" ;;
+ esac
+
+ # PEM content is already Base64 inside the -----BEGIN/END----- wrappers.
+ # The API expects the raw PEM as a JSON string, so newlines must be escaped as \n.
+ _cert_pem="$(sed 's/$/\\n/' "$_cfullchain" | tr -d '\n')"
+ _key_pem="$(sed 's/$/\\n/' "$_ckey" | tr -d '\n')"
+
+ _debug2 _cert_name "$_cert_name"
+ _debug2 _domains_json "[$_domains_json]"
+
+ # Build JSON payload
+ _payload="{\"domains\":[${_domains_json}],\"certificate\":{\"certName\":\"${_cert_name}\",\"certServerData\":\"${_cert_pem}\",\"certPrivateData\":\"${_key_pem}\"}}"
+
+ # Generate BCE v1 authorization header (query string included in canonical request)
+ _cdn_path="/v2/domain/certificate"
+ _cdn_query="action=put"
+ _ts="$(_utc_date | sed 's/ /T/')Z"
+ _content_type="application/json; charset=utf-8"
+ _payload_hash="$(printf "%s" "$_payload" | _digest sha256 hex)"
+
+ if ! _baidu_cdn_bce_auth "POST" "$_cdn_path" "$_cdn_query" "$BAIDU_CDN_HOST" "$_ts" "3600" "$_content_type" "$_payload_hash"; then
+ _err "Failed to sign request"
+ return 1
+ fi
+
+ _H1="Authorization: $_BAIDU_CDN_BCE_AUTH_RESULT"
+ _H2="x-bce-date: $_ts"
+ _H3="x-bce-content-sha256: $_payload_hash"
+ _H4="Host: $BAIDU_CDN_HOST"
+ _H5=""
+
+ _url="https://${BAIDU_CDN_HOST}${_cdn_path}?${_cdn_query}"
+ response="$(_post "$_payload" "$_url" "" "POST" "$_content_type")"
+ if [ "$?" != "0" ]; then
+ _err "Failed to call Baidu Cloud CDN API"
+ return 1
+ fi
+
+ _debug2 response "$response"
+
+ if _contains "$response" "\"certId\""; then
+ _info "Certificate deployed to Baidu Cloud CDN for: $DEPLOY_BAIDU_CDN_DOMAIN"
+ return 0
+ fi
+
+ _err "Failed to deploy certificate to Baidu Cloud CDN: $response"
+ return 1
+}
+
+# BCE v1 signing with canonical query string support.
+# The CDN endpoint uses ?action=put so it must be included in the canonical request.
+_baidu_cdn_bce_auth() {
+ _method="$1"
+ _uri="$2"
+ _query="$3"
+ _host="$4"
+ _ts="$5"
+ _expire="$6"
+ _ct="$7"
+ _payload_hash="$8"
+
+ _BAIDU_CDN_BCE_AUTH_RESULT=""
+
+ _auth_prefix="bce-auth-v1/${Baidu_AK}/${_ts}/${_expire}"
+ _signed_headers="content-type;host;x-bce-content-sha256;x-bce-date"
+ _canonical_uri="$(_baidu_cdn_bce_encode_path "$_uri")"
+
+ _host_e="$(printf "%s" "$_host" | _url_encode upper-hex)"
+ _date_e="$(printf "%s" "$_ts" | _url_encode upper-hex)"
+ _ct_e="$(printf "%s" "$_ct" | _url_encode upper-hex)"
+ _hash_e="$(printf "%s" "$_payload_hash" | _url_encode upper-hex)"
+
+ _canonical_headers="content-type:${_ct_e}
+host:${_host_e}
+x-bce-content-sha256:${_hash_e}
+x-bce-date:${_date_e}"
+
+ _canonical_request="${_method}
+${_canonical_uri}
+${_query}
+${_canonical_headers}"
+
+ _sk_hex="$(printf "%s" "$Baidu_SK" | _hex_dump | tr -d " ")"
+ _signing_key="$(_baidu_cdn_hmac_sha256_hexkey "$_sk_hex" "$_auth_prefix")"
+ _signing_key_hex="$(printf "%s" "$_signing_key" | _hex_dump | tr -d " ")"
+ _signature="$(_baidu_cdn_hmac_sha256_hexkey "$_signing_key_hex" "$_canonical_request")"
+
+ _BAIDU_CDN_BCE_AUTH_RESULT="${_auth_prefix}/${_signed_headers}/${_signature}"
+}
+
+_baidu_cdn_load_credentials() {
+ Baidu_AK="${Baidu_AK:-$(_readaccountconf_mutable Baidu_AK)}"
+ Baidu_SK="${Baidu_SK:-$(_readaccountconf_mutable Baidu_SK)}"
+
+ Baidu_AK="$(_baidu_cdn_trim_ws "$Baidu_AK")"
+ Baidu_SK="$(_baidu_cdn_trim_ws "$Baidu_SK")"
+
+ if [ -z "$Baidu_AK" ] || [ -z "$Baidu_SK" ]; then
+ _err "Baidu_AK and Baidu_SK are required"
+ return 1
+ fi
+
+ _saveaccountconf_mutable Baidu_AK "$Baidu_AK"
+ _saveaccountconf_mutable Baidu_SK "$Baidu_SK"
+
+ return 0
+}
+
+_baidu_cdn_bce_encode_path() {
+ _p="$1"
+ _out=""
+ if [ "${_p#"/"}" != "$_p" ]; then
+ _out="/"
+ fi
+
+ _rest="${_p#/}"
+ while [ -n "$_rest" ]; do
+ _seg="${_rest%%/*}"
+ if [ "$_seg" ]; then
+ if [ -z "$_out" ] || [ "$_out" = "/" ]; then
+ _out="${_out}$(printf "%s" "$_seg" | _url_encode upper-hex)"
+ else
+ _out="${_out}/$(printf "%s" "$_seg" | _url_encode upper-hex)"
+ fi
+ fi
+ if [ "${_rest#*/}" = "$_rest" ]; then
+ break
+ fi
+ _rest="${_rest#*/}"
+ done
+
+ if [ -z "$_out" ]; then
+ _out="/"
+ fi
+ printf "%s" "$_out"
+}
+
+_baidu_cdn_trim_ws() {
+ printf "%s" "$1" | tr '\r\n\t' ' ' | tr -s ' ' | sed 's/^ *//;s/ *$//'
+}
+
+_baidu_cdn_json_escape() {
+ _s="$1"
+ _s="$(printf "%s" "$_s" | tr -d '\r\n')"
+ printf "%s" "$_s" |
+ sed 's/\\/\\\\/g; s/ /\\t/g' |
+ _baidu_cdn_json_encode
+}
+
+_baidu_cdn_json_encode() {
+ _j_str="$(sed 's/"/\\"/g' | sed "s/\r/\\r/g")"
+ printf "%s" "$_j_str" | _hex_dump | _lower_case | sed 's/0a/5c 6e/g' | tr -d ' ' | _h2b | tr -d "\r\n"
+}
+
+_baidu_cdn_hmac_sha256_hexkey() {
+ _key_hex="$1"
+ _msg="$2"
+ printf "%s" "$_msg" | _hmac sha256 "$_key_hex" hex
+}
diff --git a/deploy/byteplus_alb.sh b/deploy/byteplus_alb.sh
index 8443bb99..394b431f 100644
--- a/deploy/byteplus_alb.sh
+++ b/deploy/byteplus_alb.sh
@@ -163,8 +163,8 @@ byteplus_alb_deploy() {
# ── 3. Read cert and key ─────────────────────────────────────────────────────
# BytePlus requires NO blank lines between PEM blocks in the certificate chain
- _public_key=$(sed '/^[[:space:]]*$/d' "$_cfullchain" | tr -d '\r')
- _private_key=$(sed '/^[[:space:]]*$/d' "$_ckey" | tr -d '\r')
+ _public_key=$(_strip_blank_lines <"$_cfullchain" | tr -d '\r')
+ _private_key=$(_strip_blank_lines <"$_ckey" | tr -d '\r')
if [ -z "$_public_key" ] || [ -z "$_private_key" ]; then
_err "Failed to read certificate or key file."
diff --git a/deploy/cpanel_uapi.sh b/deploy/cpanel_uapi.sh
index e5381b61..02ef6b3e 100644
--- a/deploy/cpanel_uapi.sh
+++ b/deploy/cpanel_uapi.sh
@@ -52,7 +52,15 @@ cpanel_uapi_deploy() {
# read cert and key files and urlencode both
_cert=$(_url_encode <"$_ccert")
- _key=$(_url_encode <"$_ckey")
+ # with --signcsr the private key was never handed to acme.sh, so the key
+ # file does not exist; skip it instead of spilling a shell redirection
+ # error on every renewal (cPanel keeps using the already-installed key)
+ if [ -f "$_ckey" ]; then
+ _key=$(_url_encode <"$_ckey")
+ else
+ _debug "Key file $_ckey does not exist (csr mode), not sending a key."
+ _key=""
+ fi
_debug2 _cert "$_cert"
_debug2 _key "$_key"
@@ -79,7 +87,11 @@ cpanel_uapi_deploy() {
# Auto mode
if [ "$DEPLOY_CPANEL_AUTO_ENABLED" = "true" ]; then
# call API for site config
- _response=$(uapi DomainInfo list_domains)
+ if [ -n "$_uapi_user" ]; then
+ _response=$(uapi --user="$_uapi_user" DomainInfo list_domains)
+ else
+ _response=$(uapi DomainInfo list_domains)
+ fi
# exit if error in response
if [ -z "$_response" ] || [ "${_response#*"$uapi_error_response"}" != "$_response" ]; then
_err "Error in deploying certificate - cannot retrieve sitelist:"
@@ -194,7 +206,8 @@ __cpanel_parse_response() {
printf("%s%s=%s\n", prefix, $2, $3);
}
}' |
- sed -En -e 's/^result\/data\/(main_domain|sub_domains\/-|addon_domains\/-|parked_domains\/-)=(.*)$/\2/p'
+ sed -En -e 's/^result\/data\/(main_domain|sub_domains\/-|addon_domains\/-|parked_domains\/-)=(.*)$/\2/p' |
+ sed -e 's/^"//' -e 's/"$//' # YAML double-quotes values starting with '*' (wildcard subdomains)
}
# Load parameter by prefix+name - fallback to default if not set, and save to config
diff --git a/deploy/docker.sh b/deploy/docker.sh
index 264963ae..276172aa 100755
--- a/deploy/docker.sh
+++ b/deploy/docker.sh
@@ -3,6 +3,8 @@
#DEPLOY_DOCKER_CONTAINER_LABEL="xxxxxxx"
#DEPLOY_DOCKER_CONTAINER_KEY_FILE="/path/to/key.pem"
+#DEPLOY_DOCKER_CONTAINER_KEY_MODE="0640"
+#DEPLOY_DOCKER_CONTAINER_KEY_OWNER="1000:1000"
#DEPLOY_DOCKER_CONTAINER_CERT_FILE="/path/to/cert.pem"
#DEPLOY_DOCKER_CONTAINER_CA_FILE="/path/to/ca.pem"
#DEPLOY_DOCKER_CONTAINER_FULLCHAIN_FILE="/path/to/fullchain.pem"
@@ -71,6 +73,18 @@ docker_deploy() {
_savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_FILE "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"
fi
+ _getdeployconf DEPLOY_DOCKER_CONTAINER_KEY_MODE
+ _debug2 DEPLOY_DOCKER_CONTAINER_KEY_MODE "$DEPLOY_DOCKER_CONTAINER_KEY_MODE"
+ if [ "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" ]; then
+ _savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_MODE "$DEPLOY_DOCKER_CONTAINER_KEY_MODE"
+ fi
+
+ _getdeployconf DEPLOY_DOCKER_CONTAINER_KEY_OWNER
+ _debug2 DEPLOY_DOCKER_CONTAINER_KEY_OWNER "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER"
+ if [ "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" ]; then
+ _savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_OWNER "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER"
+ fi
+
_getdeployconf DEPLOY_DOCKER_CONTAINER_CERT_FILE
_debug2 DEPLOY_DOCKER_CONTAINER_CERT_FILE "$DEPLOY_DOCKER_CONTAINER_CERT_FILE"
if [ "$DEPLOY_DOCKER_CONTAINER_CERT_FILE" ]; then
@@ -112,6 +126,20 @@ docker_deploy() {
if ! _docker_cp "$_cid" "$_ckey" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then
return 1
fi
+ if [ "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" ]; then
+ _info "Setting key file owner to $DEPLOY_DOCKER_CONTAINER_KEY_OWNER"
+ if ! _docker_exec "$_cid" chown "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then
+ _err "Can not change owner of key file in container"
+ return 1
+ fi
+ fi
+ if [ "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" ]; then
+ _info "Setting key file mode to $DEPLOY_DOCKER_CONTAINER_KEY_MODE"
+ if ! _docker_exec "$_cid" chmod "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then
+ _err "Can not change mode of key file in container"
+ return 1
+ fi
+ fi
fi
if [ "$DEPLOY_DOCKER_CONTAINER_CERT_FILE" ]; then
@@ -189,10 +217,22 @@ _docker_exec() {
_debug2 cjson "$cjson"
execid="$(echo "$cjson" | cut -d '"' -f 4)"
_debug execid "$execid"
- ejson="$(_curl_unix_sock "$_DOCKER_SOCK" POST "/exec/$execid/start" "{\"Detach\": false,\"Tty\": false}")"
+ #Detach:true is required for podman's docker-compatible API: with
+ #Detach:false it streams the command output on the connection, so the
+ #non-empty response was misread as an error (issue #4977). The real
+ #result is checked via the exec inspect ExitCode below instead.
+ ejson="$(_curl_unix_sock "$_DOCKER_SOCK" POST "/exec/$execid/start" "{\"Detach\": true,\"Tty\": false}")"
_debug2 ejson "$ejson"
- if [ "$ejson" ]; then
- _err "$ejson"
+ _et=0
+ ijson="$(_curl_unix_sock "$_DOCKER_SOCK" GET "/exec/$execid/json")"
+ while _contains "$ijson" "\"Running\":true" && [ "$_et" -lt 10 ]; do
+ sleep 1
+ _et="$(_math "$_et" + 1)"
+ ijson="$(_curl_unix_sock "$_DOCKER_SOCK" GET "/exec/$execid/json")"
+ done
+ _debug2 ijson "$ijson"
+ if ! echo "$ijson" | _egrep_o "\"ExitCode\": *0[,}]" >/dev/null 2>&1; then
+ _err "docker exec error: $ijson"
return 1
fi
else
diff --git a/deploy/fortigate.sh b/deploy/fortigate.sh
new file mode 100644
index 00000000..f00ca1cb
--- /dev/null
+++ b/deploy/fortigate.sh
@@ -0,0 +1,175 @@
+#!/usr/bin/env sh
+# Script to deploy a certificate to FortiGate via API and set it as the current web GUI certificate.
+#
+# FortiGate's native ACME integration does not support wildcard certificates or domain validation,
+# and is not supported if you have a custom management web port (eg. DNAT web traffic).
+#
+# REQUIRED:
+# export FGT_HOST="fortigate_hostname-or-ip"
+# export FGT_TOKEN="fortigate_api_token"
+#
+# OPTIONAL:
+# export FGT_PORT="10443" # Custom HTTPS port (defaults to 443 if not set)
+#
+# Run `acme.sh --deploy -d example.com --deploy-hook fortigate --insecure` to use this script.
+# `--insecure` is required on first run if not already using a valid SSL certificate on firewall.
+
+# Function to parse a FortiGate API response
+_fortigate_parse_response() {
+ _fortigate_response="$1"
+ _fortigate_func="$2"
+ _fortigate_status=$(echo "$_fortigate_response" | _egrep_o '"status":[ ]*"[^"]*"' | cut -d '"' -f 4)
+
+ if [ "$_fortigate_status" != "success" ]; then
+ _err "[$_fortigate_func] Operation failed. Deploy with --insecure if current certificate is invalid. Try deploying with --debug to troubleshoot."
+ return 1
+ fi
+
+ _debug "[$_fortigate_func] Operation successful."
+ return 0
+}
+
+# Function to deploy a base64-encoded certificate to the firewall
+_fortigate_deployer() {
+ _fortigate_cert_base64=$(_base64 <"$_fortigate_cfullchain" | tr -d '\n')
+ _fortigate_key_base64=$(_base64 <"$_fortigate_ckey" | tr -d '\n')
+ _fortigate_payload=$(
+ cat <"
+
+ return 0
+}
diff --git a/deploy/keyhelp.sh b/deploy/keyhelp.sh
index 97f9c21c..f66d27ce 100644
--- a/deploy/keyhelp.sh
+++ b/deploy/keyhelp.sh
@@ -83,7 +83,7 @@ keyhelp_deploy() {
_request_body="submit=1&certificate_name=$certificate_name&add_type=upload&text_private_key=$encoded_key&text_certificate=$encoded_ccert&text_ca_certificate=$encoded_cca"
_H1="Cookie: $_cookie"
_response=$(_post "$_request_body" "$DEPLOY_KEYHELP_BASEURL/index.php?page=ssl_certificates&action=add" "" "POST")
- _message=$(echo "$_response" | grep -A 2 'message-body' | sed -n '/
/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//')
+ _message=$(echo "$_response" | sed -n '/
/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//')
_info "_message" "$_message"
if [ -z "$_message" ]; then
_err "Fail to upload certificate."
@@ -118,7 +118,7 @@ keyhelp_deploy() {
_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/ *$//')
+ _message=$(echo "$_response" | sed -n '/
/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//')
_info "_message" "$_message"
if [ -z "$_message" ]; then
_err "Fail to apply certificate."
diff --git a/deploy/multideploy.sh b/deploy/multideploy.sh
index ef920f64..4a8c9dc9 100644
--- a/deploy/multideploy.sh
+++ b/deploy/multideploy.sh
@@ -10,6 +10,10 @@
# 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"
+# A name without a leading '/' is looked up in the certificate directory
+# of the domain. An absolute path is used as is, so a single deploy file
+# can be shared by all domains, e.g.
+# - export MULTIDEPLOY_FILENAME="/etc/acme/multideploy.yml"
#
# 2. Run command:
# acme.sh --deploy --deploy-hook multideploy -d example.com
@@ -49,7 +53,7 @@ multideploy_deploy() {
_debug _cfullchain "$_cfullchain"
_debug _cpfx "$_cpfx"
- MULTIDEPLOY_FILENAME="${MULTIDEPLOY_FILENAME:-$(_getdeployconf 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'."
@@ -75,7 +79,8 @@ multideploy_deploy() {
# 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.
+# $@ - Posible deploy file names. A name starting with '/' is treated as an
+# absolute path, any other name is relative to the domain directory.
# Usage:
# _preprocess_deployfile "" "?"
_preprocess_deployfile() {
@@ -87,15 +92,21 @@ _preprocess_deployfile() {
_debug3 "yq is installed."
# Check if deploy file exists
+ found_file=""
for file in "$@"; do
- _debug3 "Checking file" "$DOMAIN_PATH/$file"
- if [ -f "$DOMAIN_PATH/$file" ]; then
+ if _startswith "$file" "/"; then
+ _multideploy_path="$file"
+ else
+ _multideploy_path="$DOMAIN_PATH/$file"
+ fi
+ _debug3 "Checking file" "$_multideploy_path"
+ if [ -f "$_multideploy_path" ]; 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"
+ found_file="$_multideploy_path"
else
_debug3 "File not found"
fi
@@ -105,12 +116,12 @@ _preprocess_deployfile() {
_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"
+ if ! _check_deployfile "$found_file"; then
+ _err "Deploy file is not valid: $found_file"
return 1
fi
- echo "$DOMAIN_PATH/$found_file"
+ echo "$found_file"
}
# Description:
@@ -210,7 +221,7 @@ _clear_envs() {
echo "$env_pairs" | while IFS='=' read -r _key _value; do
_debug3 "Deleting key" "$_key"
- _cleardomainconf "SAVED_$_key"
+ _cleardeployconf "$_key"
unset -v "$_key"
done
}
diff --git a/deploy/mydevil.sh b/deploy/mydevil.sh
index bd9868aa..8954f822 100755
--- a/deploy/mydevil.sh
+++ b/deploy/mydevil.sh
@@ -54,6 +54,8 @@ mydevil_deploy() {
# Usage: ip=$(mydevil_get_ip domain.com)
# echo $ip
mydevil_get_ip() {
- devil dns list "$1" | cut -w -s -f 3,7 | grep "^A$(printf '\t')" | cut -w -s -f 2 || return 1
+ # tr squeezes runs of blanks into one tab so plain cut works everywhere;
+ # cut -w is BSD-only and unknown to GNU coreutils
+ devil dns list "$1" | tr -s ' \t' '\t' | cut -s -f 3,7 | grep "^A$(printf '\t')" | cut -s -f 2 || return 1
return 0
}
diff --git a/deploy/panos.sh b/deploy/panos.sh
index 00badffc..fcfd6fb5 100644
--- a/deploy/panos.sh
+++ b/deploy/panos.sh
@@ -296,9 +296,20 @@ panos_deploy() {
_err "Unable to generate an API key. The user and pass may be invalid or not authorized to generate a new key. Please check the PANOS_USER and PANOS_PASS credentials and try again"
return 1
else
- deployer cert
- deployer key
- deployer commit
+ # A commit of a failed import would leave a mismatched cert/key pair
+ # on the firewall and can lock the admin out of the management
+ # interface, see https://github.com/acmesh-official/acme.sh/issues/4716
+ if ! deployer cert; then
+ _err "Cert import failed. Aborting without committing."
+ return 1
+ fi
+ if ! deployer key; then
+ _err "Key import failed. Aborting without committing. Warning: the firewall now has an uncommitted mismatched cert/key pair in its candidate config."
+ return 1
+ fi
+ if ! deployer commit; then
+ return 1
+ fi
if [ "$_panos_template_stack" ]; then
# try to get job status for 20 times in 30 sec interval
i=0
diff --git a/deploy/proxmoxbs.sh b/deploy/proxmoxbs.sh
index 179b0369..30599a44 100644
--- a/deploy/proxmoxbs.sh
+++ b/deploy/proxmoxbs.sh
@@ -116,17 +116,24 @@ HEREDOC
export HTTPS_INSECURE=1
export _H1="Authorization: PBSAPIToken=${_proxmoxbs_header_api_token}"
response=$(_post "$_json_payload" "$_target_url" "" POST "application/json")
+ _retval=$?
+ # The API errors out with a non-2xx HTTP status and an empty body,
+ # so the status line is checked too, not only the response body.
+ _status_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ _debug2 "HTTP status" "$_status_code"
response="$(echo "$response" | _json_decode | _normalizeJson)"
message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"')
- _retval=$?
- if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
- _debug3 response "$response"
- _info "Certificate successfully deployed"
- return 0
- else
- _err "Certificate deployment failed: $message"
- _debug "Response" "$response"
- return 1
- fi
+ case "$_status_code" in
+ 2[0-9][0-9])
+ if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
+ _debug3 response "$response"
+ _info "Certificate successfully deployed"
+ return 0
+ fi
+ ;;
+ esac
+ _err "Certificate deployment failed (HTTP status $_status_code). $message"
+ _debug "Response" "$response"
+ return 1
}
diff --git a/deploy/proxmoxve.sh b/deploy/proxmoxve.sh
index b6298ee7..fd8d69d8 100644
--- a/deploy/proxmoxve.sh
+++ b/deploy/proxmoxve.sh
@@ -128,17 +128,24 @@ HEREDOC
export HTTPS_INSECURE=1
export _H1="Authorization: PVEAPIToken=${_proxmoxve_header_api_token}"
response=$(_post "$_json_payload" "$_target_url" "" POST "application/json")
+ _retval=$?
+ # The API errors out with a non-2xx HTTP status and an empty body,
+ # so the status line is checked too, not only the response body.
+ _status_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ _debug2 "HTTP status" "$_status_code"
response="$(echo "$response" | _json_decode | _normalizeJson)"
message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"')
- _retval=$?
- if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
- _debug3 response "$response"
- _info "Certificate successfully deployed"
- return 0
- else
- _err "Certificate deployment failed: $message"
- _debug "Response" "$response"
- return 1
- fi
+ case "$_status_code" in
+ 2[0-9][0-9])
+ if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
+ _debug3 response "$response"
+ _info "Certificate successfully deployed"
+ return 0
+ fi
+ ;;
+ esac
+ _err "Certificate deployment failed (HTTP status $_status_code). $message"
+ _debug "Response" "$response"
+ return 1
}
diff --git a/deploy/routeros.sh b/deploy/routeros.sh
index ef9c6954..328fabbd 100644
--- a/deploy/routeros.sh
+++ b/deploy/routeros.sh
@@ -125,7 +125,7 @@ routeros_deploy() {
_savedeployconf ROUTER_OS_PORT "$ROUTER_OS_PORT"
_savedeployconf ROUTER_OS_SSH_CMD "$ROUTER_OS_SSH_CMD"
_savedeployconf ROUTER_OS_SCP_CMD "$ROUTER_OS_SCP_CMD"
- _savedeployconf ROUTER_OS_ADDITIONAL_SERVICES "$ROUTER_OS_ADDITIONAL_SERVICES"
+ _savedeployconf ROUTER_OS_ADDITIONAL_SERVICES "$ROUTER_OS_ADDITIONAL_SERVICES" "base64"
# push key to routeros
if ! _scp_certificate "$_ckey" "$ROUTER_OS_USERNAME@$ROUTER_OS_HOST:$_cdomain.key"; then
@@ -143,6 +143,7 @@ comment=\"generated by routeros deploy script in acme.sh\" \
source=\"/certificate remove [ find name=$_cdomain.cer_0 ];\
\n/certificate remove [ find name=$_cdomain.cer_1 ];\
\n/certificate remove [ find name=$_cdomain.cer_2 ];\
+\n/certificate remove [ find name=$_cdomain.cer_3 ];\
\ndelay 1;\
\n/certificate import file-name=\\\"$_cdomain.cer\\\" passphrase=\\\"\\\";\
\n/certificate import file-name=\\\"$_cdomain.key\\\" passphrase=\\\"\\\";\
diff --git a/deploy/shelly.sh b/deploy/shelly.sh
new file mode 100644
index 00000000..dbdab346
--- /dev/null
+++ b/deploy/shelly.sh
@@ -0,0 +1,280 @@
+#!/usr/bin/env sh
+
+# Here is a script to deploy cert to a Shelly Gen3+ device.
+# Deploy the HTTPS server certificate to a Shelly device on the local network.
+#
+# ```sh
+# export SHELLY_HOST=192.168.1.100
+# export SHELLY_PASSWORD=mysecret # only if auth is enabled on the device
+# acme.sh --deploy -d shelly.example.com --deploy-hook shelly
+# ```
+#
+# Environment variables:
+# SHELLY_HOST (required) IP or hostname of the Shelly device
+# SHELLY_PASSWORD (optional) Admin password for digest authentication.
+# Omit if auth is disabled on the device.
+# SHELLY_USER (optional) Username for auth. Default: admin
+# SHELLY_REBOOT (optional) Set to "0" to skip auto-reboot.
+# Default: 1 (reboot after upload)
+#
+# Requirements:
+# - Shelly Gen3+ device (Gen4 recommended)
+# - Firmware 2.0.0+ for HTTPS server certificate support
+# - curl or wget
+# - openssl (for SHA-256 digest and random cnonce)
+#
+# The device must be reachable via HTTP on the local network.
+# The hook uploads the fullchain.pem and private key,
+# then reboots the device to apply the new certificate.
+#
+# Authentication uses standard RFC 7616 HTTP Digest (SHA-256) since
+# firmware 2.0.0. The JSON-RPC auth object is not used for HTTP transport.
+#
+# returns 0 means success, otherwise error.
+
+######## Public functions #####################
+
+#domain keyfile certfile cafile fullchain
+shelly_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"
+
+ _getdeployconf SHELLY_HOST
+ _getdeployconf SHELLY_PASSWORD
+ _getdeployconf SHELLY_USER
+ _getdeployconf SHELLY_REBOOT
+
+ _debug SHELLY_HOST "$SHELLY_HOST"
+ _debug SHELLY_USER "$SHELLY_USER"
+ _secure_debug SHELLY_PASSWORD "$SHELLY_PASSWORD"
+ _debug SHELLY_REBOOT "$SHELLY_REBOOT"
+
+ if [ -z "$SHELLY_HOST" ]; then
+ _err "SHELLY_HOST is required. Please set the IP or hostname of your Shelly device."
+ return 1
+ fi
+
+ SHELLY_USER="${SHELLY_USER:-admin}"
+ SHELLY_REBOOT="${SHELLY_REBOOT:-1}"
+
+ _savedeployconf SHELLY_HOST "$SHELLY_HOST"
+ _savedeployconf SHELLY_PASSWORD "$SHELLY_PASSWORD"
+ _savedeployconf SHELLY_USER "$SHELLY_USER"
+ _savedeployconf SHELLY_REBOOT "$SHELLY_REBOOT"
+
+ # --- Auth handshake (only if password is set) ---
+ _shelly_auth_header=""
+ if [ -n "$SHELLY_PASSWORD" ]; then
+ _info "Authenticating to Shelly device at $SHELLY_HOST"
+ if ! _shelly_handshake; then
+ _err "Authentication handshake failed. Check SHELLY_PASSWORD and device accessibility."
+ return 1
+ fi
+ _info "Authentication successful"
+ fi
+
+ # --- Upload certificate ---
+ _info "Uploading certificate to Shelly device at $SHELLY_HOST"
+ if ! _shelly_upload_cert; then
+ _err "Certificate upload failed"
+ return 1
+ fi
+
+ # --- Upload key ---
+ _info "Uploading private key to Shelly device"
+ if ! _shelly_upload_key; then
+ _err "Private key upload failed"
+ return 1
+ fi
+
+ _info "Certificate and key uploaded successfully"
+
+ # --- Reboot ---
+ if [ "$SHELLY_REBOOT" != "0" ]; then
+ _info "Rebooting Shelly device to apply certificate"
+ # Reboot may close the connection before sending a response
+ _shelly_rpc "Shelly.Reboot" '{}' || _debug "Reboot may have closed connection (expected)"
+ _info "Reboot command sent. Device will restart shortly."
+ else
+ _info "Skipping reboot (SHELLY_REBOOT=0). Certificate will apply on next restart."
+ fi
+
+ # Clear auth header so it does not leak to other hooks
+ export _H1=""
+
+ return 0
+}
+
+# --- Helper functions ---
+
+# Perform RFC 7616 HTTP Digest auth handshake.
+# Sets _shelly_auth_header on success (the Authorization header value).
+_shelly_handshake() {
+ _inithttp
+
+ _debug "Probing device for auth challenge"
+
+ # Use a protected method (Shelly.GetStatus) to trigger 401.
+ # Shelly.GetDeviceInfo is excluded from auth and would miss the challenge.
+ _post '{"id":1,"method":"Shelly.GetStatus"}' \
+ "http://${SHELLY_HOST}/rpc" "" "" "application/json"
+
+ # Detect auth from HTTP status line rather than response body
+ if ! _shelly_has_auth_challenge "$HTTP_HEADER"; then
+ # No auth challenge — device accepted the request without credentials
+ _debug "Device responded without auth challenge. Proceeding without auth."
+ return 0
+ fi
+
+ _shelly_realm="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*realm="//;s/".*//')"
+ _shelly_nonce="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*nonce="//;s/".*//')"
+ _shelly_qop="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*qop="//;s/".*//')"
+
+ if [ -z "$_shelly_nonce" ]; then
+ _err "Failed to extract nonce from WWW-Authenticate header. Is SHELLY_PASSWORD correct?"
+ return 1
+ fi
+
+ _shelly_qop="${_shelly_qop:-auth}"
+
+ _debug "Shelly realm: $_shelly_realm"
+ _debug "Shelly qop: $_shelly_qop"
+ _secure_debug "Shelly nonce" "$_shelly_nonce"
+
+ # ha1 = SHA256(username:realm:password)
+ _shelly_ha1="$(printf '%s' "${SHELLY_USER}:${_shelly_realm}:${SHELLY_PASSWORD}" | _digest sha256 hex)"
+ _secure_debug "Shelly ha1" "$_shelly_ha1"
+
+ # Generate client nonce (openssl is required for _digest, so always available)
+ _shelly_cnonce="$(${ACME_OPENSSL_BIN:-openssl} rand -hex 8 2>/dev/null)"
+ _debug "Shelly cnonce: $_shelly_cnonce"
+
+ # Build the digest Authorization header value (stored for reuse)
+ _shelly_nc=1
+ _shelly_build_auth_header
+
+ return 0
+}
+
+# Check whether the HTTP response headers contain a digest auth challenge.
+# Returns 0 (true) if a 401 with WWW-Authenticate is present.
+_shelly_has_auth_challenge() {
+ _shelly_headers_file="$1"
+ _shelly_status="$(grep -i '^HTTP/' "$_shelly_headers_file" | _tail_n 1 | awk '{print $2}')"
+ [ "$_shelly_status" = "401" ] && grep -qi '^WWW-Authenticate:' "$_shelly_headers_file"
+}
+
+# Build or rebuild the RFC 7616 Authorization header.
+# Uses: _shelly_ha1, _shelly_nonce, _shelly_cnonce, _shelly_qop, _shelly_realm, _shelly_nc
+# Sets: _shelly_auth_header
+_shelly_build_auth_header() {
+ _shelly_nc_hex="$(printf '%08x' "$_shelly_nc")"
+
+ # ha2 = SHA256(POST:/rpc)
+ _shelly_ha2="$(printf '%s' "POST:/rpc" | _digest sha256 hex)"
+
+ # response = SHA256(ha1:nonce:nc:cnonce:qop:ha2)
+ _shelly_digest_response="$(printf '%s' "${_shelly_ha1}:${_shelly_nonce}:${_shelly_nc_hex}:${_shelly_cnonce}:${_shelly_qop}:${_shelly_ha2}" | _digest sha256 hex)"
+
+ # Build the Authorization header value (without the "Authorization: " prefix)
+ _shelly_auth_header="Digest username=\"${SHELLY_USER}\", realm=\"${_shelly_realm}\", nonce=\"${_shelly_nonce}\", uri=\"/rpc\", qop=${_shelly_qop}, nc=${_shelly_nc_hex}, cnonce=\"${_shelly_cnonce}\", response=\"${_shelly_digest_response}\", algorithm=SHA-256"
+
+ _secure_debug "Authorization header" "$_shelly_auth_header"
+}
+
+# Make a Shelly JSON-RPC call.
+# Usage: _shelly_rpc
+# Returns 0 on success, 1 on error.
+_shelly_rpc() {
+ _shelly_method="$1"
+ _shelly_params="$2"
+
+ _shelly_body='{"id":1,"method":"'"$_shelly_method"'","params":'"$_shelly_params"'}'
+
+ _debug "RPC method: $_shelly_method"
+ _debug2 "RPC body: $_shelly_body"
+
+ # shellcheck disable=SC2090
+ if [ -n "$_shelly_auth_header" ]; then
+ export _H1="Authorization: $_shelly_auth_header"
+ else
+ export _H1=""
+ fi
+
+ _post "$_shelly_body" "http://${SHELLY_HOST}/rpc" "" "" "application/json"
+ _shelly_ret=$?
+
+ if [ "$_shelly_ret" != "0" ]; then
+ _err "HTTP request failed for $_shelly_method (curl/wget error $_shelly_ret)"
+ return 1
+ fi
+
+ # Empty response means something went wrong (auth required but not provided, etc.)
+ if [ -z "$response" ]; then
+ _err "Empty response from Shelly device. If authentication is enabled on the device, set SHELLY_PASSWORD."
+ return 1
+ fi
+
+ # Validate response looks like a Shelly JSON-RPC response.
+ # Catches non-JSON responses such as HTTP 429 "Too Many Requests" which
+ # would otherwise pass the empty and "error" checks below.
+ if ! _startswith "$response" '{' || ! _contains "$response" '"id"'; then
+ _err "Invalid response from Shelly device: $response"
+ return 1
+ fi
+
+ # Check for JSON-RPC error in response
+ if _contains "$response" '"error"'; then
+ _err "RPC error from Shelly: $response"
+ return 1
+ fi
+
+ _debug "RPC response: $response"
+
+ # Increment nonce counter and rebuild auth header for next request
+ if [ -n "$_shelly_auth_header" ]; then
+ _shelly_nc=$((_shelly_nc + 1))
+ _shelly_build_auth_header
+ fi
+
+ return 0
+}
+
+# Upload the certificate to the device.
+# Note: We do NOT clear the existing certificate first, because the Shelly
+# auto-removes all three files (cert, key, CA) when any one is cleared.
+# Uploading overwrites in place — no clearing needed.
+_shelly_upload_cert() {
+ _shelly_cert_data="$(_json_encode <"$_cfullchain")"
+
+ _debug "Uploading certificate"
+ if ! _shelly_rpc "Shelly.PutHTTPServerCert" '{"data":"'"$_shelly_cert_data"'"}'; then
+ _err "Failed to upload certificate to device"
+ return 1
+ fi
+
+ return 0
+}
+
+# Upload the private key to the device.
+# Note: Do not clear first — see _shelly_upload_cert for rationale.
+_shelly_upload_key() {
+ _shelly_key_data="$(_json_encode <"$_ckey")"
+
+ _debug "Uploading key"
+ if ! _shelly_rpc "Shelly.PutHTTPServerKey" '{"data":"'"$_shelly_key_data"'"}'; then
+ _err "Failed to upload key to device"
+ return 1
+ fi
+
+ return 0
+}
diff --git a/deploy/ssh.sh b/deploy/ssh.sh
index 848380a5..0bf3ee48 100644
--- a/deploy/ssh.sh
+++ b/deploy/ssh.sh
@@ -25,7 +25,8 @@
# export DEPLOY_SSH_MULTI_CALL="" # yes or no, default to no or previously saved value
# export DEPLOY_SSH_USE_SCP="" yes or no, default to no
# export DEPLOY_SSH_SCP_CMD="" defaults to "scp -q"
-#
+# export DEPLOY_SSH_REMOTE_SHELL="" # defaults to sh -c
+# export DEPLOY_SSH_REMOTE_CMD_QUOTE="" # yes or no, defaults to yes
######## Public functions #####################
#domain keyfile certfile cafile fullchain
@@ -71,6 +72,24 @@ ssh_deploy() {
fi
_savedeployconf DEPLOY_SSH_CMD "$DEPLOY_SSH_CMD"
+ # REMOTE_SHELL is optional. If not provided then use sh
+ _migratedeployconf Le_Deploy_ssh_remote_shell DEPLOY_SSH_REMOTE_SHELL
+ _getdeployconf DEPLOY_SSH_REMOTE_SHELL
+ _debug2 DEPLOY_SSH_REMOTE_SHELL "$DEPLOY_SSH_REMOTE_SHELL"
+ if [ -z "$DEPLOY_SSH_REMOTE_SHELL" ]; then
+ DEPLOY_SSH_REMOTE_SHELL="sh -c"
+ fi
+ _savedeployconf DEPLOY_SSH_REMOTE_SHELL "$DEPLOY_SSH_REMOTE_SHELL"
+
+ # REMOTE_CMD_QUOTE is optional. If not provided then yes
+ _migratedeployconf Le_Deploy_ssh_remote_cmd_quote DEPLOY_SSH_REMOTE_CMD_QUOTE
+ _getdeployconf DEPLOY_SSH_REMOTE_CMD_QUOTE
+ _debug2 DEPLOY_SSH_REMOTE_CMD_QUOTE "$DEPLOY_SSH_REMOTE_CMD_QUOTE"
+ if [ -z "$DEPLOY_SSH_REMOTE_CMD_QUOTE" ]; then
+ DEPLOY_SSH_REMOTE_CMD_QUOTE="yes"
+ fi
+ _savedeployconf DEPLOY_SSH_REMOTE_CMD_QUOTE "$DEPLOY_SSH_REMOTE_CMD_QUOTE"
+
# BACKUP is optional. If not provided then default to previously saved value or yes.
_migratedeployconf Le_Deploy_ssh_backup DEPLOY_SSH_BACKUP
_getdeployconf DEPLOY_SSH_BACKUP
@@ -170,10 +189,16 @@ ssh_deploy() {
_info "Required commands batched and sent in single call to remote host"
fi
+ _returnCode=0
_deploy_ssh_servers="$DEPLOY_SSH_SERVER"
for DEPLOY_SSH_SERVER in $_deploy_ssh_servers; do
- _ssh_deploy
+ if ! _ssh_deploy; then
+ # in case of an error, remember it, but keep going for the remaining servers
+ _returnCode=1
+ fi
done
+
+ return $_returnCode
}
_ssh_deploy() {
@@ -428,9 +453,13 @@ _ssh_remote_cmd() {
_secure_debug "Remote commands to execute: $_cmd"
_info "Submitting sequence of commands to remote server by $_ssh_cmd"
- # quotations in bash cmd below intended. Squash travis spellcheck error
- # shellcheck disable=SC2029
- $_ssh_cmd "$DEPLOY_SSH_USER@$_host" sh -c "'$_cmd'"
+ if [ "$DEPLOY_SSH_REMOTE_CMD_QUOTE" = "yes" ]; then
+ # quotations in bash cmd below intended. Squash travis spellcheck error
+ # shellcheck disable=SC2029
+ $_ssh_cmd "$DEPLOY_SSH_USER@$_host" "$DEPLOY_SSH_REMOTE_SHELL" "'$_cmd'"
+ else
+ $_ssh_cmd "$DEPLOY_SSH_USER@$_host" "$DEPLOY_SSH_REMOTE_SHELL" "$_cmd"
+ fi
_err_code="$?"
if [ "$_err_code" != "0" ]; then
diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh
index e28a4036..336980a5 100644
--- a/deploy/synology_dsm.sh
+++ b/deploy/synology_dsm.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env sh
################################################################################
# ACME.sh 3rd party deploy plugin for Synology DSM
@@ -72,7 +72,7 @@ synology_dsm_deploy() {
if [ -n "$SYNO_USE_TEMP_ADMIN" ]; then
if ! _exists synouser || ! _exists synogroup || ! _exists synosetkeyvalue; then
- _err "Missing required tools to creat temp admin user, please set SYNO_USERNAME and SYNO_PASSWORD instead."
+ _err "Missing required tools to create temp admin user, please set SYNO_USERNAME and SYNO_PASSWORD instead."
_err "Notice: temp admin user authorization method only supports local deployment on DSM."
return 1
fi
@@ -234,11 +234,11 @@ synology_dsm_deploy() {
fi
fi
- error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -o '[0-9]*')
+ error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -Eo '[0-9]+')
_debug2 error_code "$error_code"
# Account has 2FA-OTP enabled, since error 403 reported.
# https://global.download.synology.com/download/Document/Software/DeveloperGuide/Os/DSM/All/enu/DSM_Login_Web_API_Guide_enu.pdf
- if [ "$error_code" == "403" ]; then
+ if [ "$error_code" = "403" ]; then
if [ -z "$SYNO_DEVICE_NAME" ]; then
printf "Enter device name or leave empty for default (CertRenewal): "
read -r SYNO_DEVICE_NAME
@@ -269,27 +269,27 @@ synology_dsm_deploy() {
_secure_debug2 SYNO_DEVICE_ID "$SYNO_DEVICE_ID"
fi
fi
- error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -o '[0-9]*')
+ error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -Eo '[0-9]+')
_debug2 error_code "$error_code"
fi
if [ -n "$error_code" ]; then
- if [ "$error_code" == "403" ] && [ -n "$SYNO_DEVICE_ID" ]; then
+ if [ "$error_code" = "403" ] && [ -n "$SYNO_DEVICE_ID" ]; then
_cleardeployconf SYNO_DEVICE_ID
- _err "Failed to authenticate with SYNO_DEVICE_ID (may expired or invalid), please try again in a new terminal window."
- elif [ "$error_code" == "404" ]; then
+ _err "Failed to authenticate with SYNO_DEVICE_ID (may be expired or invalid), please try again in a new terminal window."
+ elif [ "$error_code" = "404" ]; then
_err "Failed to authenticate with provided 2FA-OTP code, please try again in a new terminal window."
- elif [ "$error_code" == "406" ]; then
+ elif [ "$error_code" = "406" ]; then
if [ -n "$SYNO_USE_TEMP_ADMIN" ]; then
_err "Failed with unexcepted error, please report this by providing full log with '--debug 3'."
else
_err "Enforce auth with 2FA-OTP enabled, please configure the user to enable 2FA-OTP to continue."
fi
- elif [ "$error_code" == "400" ]; then
+ elif [ "$error_code" = "400" ]; then
_err "Failed to authenticate, no such account or incorrect password."
- elif [ "$error_code" == "401" ]; then
+ elif [ "$error_code" = "401" ]; then
_err "Failed to authenticate with a non-existent account."
- elif [ "$error_code" == "408" ] || [ "$error_code" == "409" ] || [ "$error_code" == "410" ]; then
+ elif [ "$error_code" = "408" ] || [ "$error_code" = "409" ] || [ "$error_code" = "410" ]; then
_err "Failed to authenticate, the account password has expired or must be changed."
else
_err "Failed to authenticate with error: $error_code."
@@ -322,8 +322,8 @@ synology_dsm_deploy() {
_savedeployconf SYNO_USE_TEMP_ADMIN "$SYNO_USE_TEMP_ADMIN"
_savedeployconf SYNO_LOCAL_HOSTNAME "$SYNO_LOCAL_HOSTNAME"
else
- _savedeployconf SYNO_USERNAME "$SYNO_USERNAME"
- _savedeployconf SYNO_PASSWORD "$SYNO_PASSWORD"
+ _savedeployconf SYNO_USERNAME "$SYNO_USERNAME" "base64"
+ _savedeployconf SYNO_PASSWORD "$SYNO_PASSWORD" "base64"
_savedeployconf SYNO_DEVICE_ID "$SYNO_DEVICE_ID"
_savedeployconf SYNO_DEVICE_NAME "$SYNO_DEVICE_NAME"
fi
@@ -336,7 +336,7 @@ synology_dsm_deploy() {
id=$(echo "$response" | sed -n "s/.*\"desc\":\"$escaped_certificate\",\"id\":\"\([^\"]*\).*/\1/p")
_debug2 id "$id"
- error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -o '[0-9]*')
+ error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -Eo '[0-9]+')
_debug2 error_code "$error_code"
if [ -n "$error_code" ]; then
if [ "$error_code" -eq 105 ]; then
@@ -344,6 +344,7 @@ synology_dsm_deploy() {
else
_err "Failed to fetch certificate info: $error_code, please try again or contact Synology to learn more."
fi
+ _logout
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
return 1
fi
@@ -354,6 +355,7 @@ synology_dsm_deploy() {
if [ -z "$id" ] && [ -z "$SYNO_CREATE" ]; then
_err "Unable to find certificate: $SYNO_CERTIFICATE and \$SYNO_CREATE is not set."
+ _logout
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
return 1
fi
@@ -389,13 +391,13 @@ synology_dsm_deploy() {
else
_info "Restart HTTP services not necessary."
fi
- _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
_logout
+ _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
return 0
else
- _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
_err "Unable to update certificate, got error response: $response."
_logout
+ _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
return 1
fi
}
@@ -403,6 +405,8 @@ synology_dsm_deploy() {
#################### Private functions below ##################################
_logout() {
# Logout CERT user only to not occupy a permanent session, e.g. in DSM's "Connected Users" widget (based on previous variables)
+ # Must be called before _temp_admin_cleanup: once the temp admin is deleted, its session can no longer be logged out.
+ # Note: this overwrites $response, so print any error message that needs it before calling.
response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=logout&_sid=$sid")
_debug3 response "$response"
}
@@ -424,11 +428,6 @@ _temp_admin_cleanup() {
fi
}
-#_cleardeployconf key
-_cleardeployconf() {
- _cleardomainconf "SAVED_$1"
-}
-
# key
_check2cleardeployconfexp() {
_key="$1"
diff --git a/deploy/truenas_ws.sh b/deploy/truenas_ws.sh
index df34f927..33e3dfa0 100644
--- a/deploy/truenas_ws.sh
+++ b/deploy/truenas_ws.sh
@@ -16,7 +16,12 @@
#
# # API KEY
# # Use the folowing URL to create a new API token: /ui/apikeys
-# export DEPLOY_TRUENAS_APIKEY="/dev/null)"
+ if [ -z "$otpcode" ]; then
+ _err "Failed to generate TOTP code from One984HOSTING_TOTP_Secret."
+ return 1
+ fi
+ otpkey="$(printf '%s' "$otpcode" | _url_encode)"
+ fi
+
+ # Fetch the login page to obtain CSRF and session cookies.
+ # Note: _get sets the global 'url', so assign the auth URL afterwards.
+ _get "https://1984.hosting/accounts/login/" >/dev/null
+ csrftoken="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'csrftoken=[^;]*;' | _head_n 1 | tr -d ';')"
+ sessionid="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'cookie1984nammnamm=[^;]*;' | _head_n 1 | tr -d ';')"
if [ -z "$csrftoken" ] || [ -z "$sessionid" ]; then
_err "One or more cookies are empty: '$csrftoken', '$sessionid'."
@@ -140,17 +158,23 @@ _1984hosting_login() {
csrf_header=$(echo "$csrftoken" | sed 's/csrftoken=//' | _head_n 1)
export _H3="X-CSRFToken: $csrf_header"
- response="$(_post "username=$username&password=$password&otpkey=" $url)"
+ url="https://1984.hosting/api/auth/"
+ response="$(_post "username=$username&password=$password&otpkey=$otpkey" "$url")"
response="$(echo "$response" | _normalizeJson)"
_debug2 response "$response"
if _contains "$response" '"loggedin": true'; then
- One984HOSTING_SESSIONID_COOKIE="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'cookie1984nammnamm=[^;]*;' | tr -d ';')"
- One984HOSTING_CSRFTOKEN_COOKIE="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'csrftoken=[^;]*;' | tr -d ';')"
+ One984HOSTING_SESSIONID_COOKIE="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'cookie1984nammnamm=[^;]*;' | _head_n 1 | tr -d ';')"
+ One984HOSTING_CSRFTOKEN_COOKIE="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'csrftoken=[^;]*;' | _head_n 1 | tr -d ';')"
export One984HOSTING_SESSIONID_COOKIE
export One984HOSTING_CSRFTOKEN_COOKIE
_saveaccountconf_mutable One984HOSTING_Username "$One984HOSTING_Username"
_saveaccountconf_mutable One984HOSTING_Password "$One984HOSTING_Password"
+ if [ -n "$One984HOSTING_TOTP_Secret" ]; then
+ _saveaccountconf_mutable One984HOSTING_TOTP_Secret "$One984HOSTING_TOTP_Secret"
+ else
+ _clearaccountconf_mutable One984HOSTING_TOTP_Secret
+ fi
_saveaccountconf_mutable One984HOSTING_SESSIONID_COOKIE "$One984HOSTING_SESSIONID_COOKIE"
_saveaccountconf_mutable One984HOSTING_CSRFTOKEN_COOKIE "$One984HOSTING_CSRFTOKEN_COOKIE"
return 0
@@ -161,6 +185,7 @@ _1984hosting_login() {
_check_credentials() {
One984HOSTING_Username="${One984HOSTING_Username:-$(_readaccountconf_mutable One984HOSTING_Username)}"
One984HOSTING_Password="${One984HOSTING_Password:-$(_readaccountconf_mutable One984HOSTING_Password)}"
+ One984HOSTING_TOTP_Secret="${One984HOSTING_TOTP_Secret:-$(_readaccountconf_mutable One984HOSTING_TOTP_Secret)}"
if [ -z "$One984HOSTING_Username" ] || [ -z "$One984HOSTING_Password" ]; then
One984HOSTING_Username=""
One984HOSTING_Password=""
@@ -225,9 +250,15 @@ _get_root() {
# Usage: _get_zone_id url domain.com
# Returns zone id for domain.com
+# Memoized per-domain so add/rm don't re-fetch the same zone list within a run.
+# Keyed on domain (not url) since the url is always the domains listing.
_get_zone_id() {
url=$1
domain=$2
+ if [ "$_zone_id_for" = "$domain" ] && [ -n "$_zone_id" ]; then
+ _debug2 _zone_id "$_zone_id (cached)"
+ return 0
+ fi
_htmlget "$url" "$domain"
_zone_id="$(echo "$_response" | _egrep_o 'zone\/[0-9]+' | _head_n 1)"
_debug2 _zone_id "$_zone_id"
@@ -235,6 +266,7 @@ _get_zone_id() {
_err "Error getting _zone_id for $2."
return 1
fi
+ _zone_id_for="$domain"
return 0
}
@@ -257,9 +289,8 @@ _htmlget() {
# Add extra headers to request
_authpost() {
- url="https://1984.hosting/domains"
- _get_zone_id "$url" "$_domain"
- csrf_header="$(echo "$One984HOSTING_CSRFTOKEN_COOKIE" | _egrep_o "=[^=][0-9a-zA-Z]*" | tr -d "=")"
+ _get_zone_id "https://1984.hosting/domains" "$_domain"
+ csrf_header="$(echo "$One984HOSTING_CSRFTOKEN_COOKIE" | sed 's/csrftoken=//' | _head_n 1)"
export _H1="Cookie: $One984HOSTING_CSRFTOKEN_COOKIE; $One984HOSTING_SESSIONID_COOKIE"
export _H2="Referer: https://1984.hosting/domains/$_zone_id"
export _H3="X-CSRFToken: $csrf_header"
diff --git a/dnsapi/dns_acmedns.sh b/dnsapi/dns_acmedns.sh
index f3f50233..a21f8ef0 100755
--- a/dnsapi/dns_acmedns.sh
+++ b/dnsapi/dns_acmedns.sh
@@ -37,6 +37,16 @@ dns_acmedns_add() {
ACMEDNS_PASSWORD="${ACMEDNS_PASSWORD:-$(_readdomainconf ACMEDNS_PASSWORD)}"
ACMEDNS_SUBDOMAIN="${ACMEDNS_SUBDOMAIN:-$(_readdomainconf ACMEDNS_SUBDOMAIN)}"
+ #for compatibility: old versions stored ACMEDNS_UPDATE_URL in the account
+ #conf (issue 3899). Do not clear it here: it must stay available for the
+ #other domains that have not migrated to their domain conf yet.
+ if [ -z "$ACMEDNS_BASE_URL" ]; then
+ _acmedns_update_url="$(_readaccountconf_mutable ACMEDNS_UPDATE_URL)"
+ if [ "$_acmedns_update_url" ]; then
+ ACMEDNS_BASE_URL="$(echo "$_acmedns_update_url" | sed 's#/update$##')"
+ fi
+ fi
+
if [ "$ACMEDNS_BASE_URL" = "" ]; then
ACMEDNS_BASE_URL="https://auth.acme-dns.io"
fi
@@ -71,7 +81,7 @@ dns_acmedns_add() {
data="{\"subdomain\":\"$ACMEDNS_SUBDOMAIN\", \"txt\": \"$txtvalue\"}"
_debug data "$data"
- response="$(_post "$data" "$ACMEDNS_UPDATE_URL" "" "POST")"
+ response="$(_post "$data" "$ACMEDNS_UPDATE_URL" "" "POST" "application/json")"
_debug response "$response"
if ! echo "$response" | grep "\"$txtvalue\"" >/dev/null; then
diff --git a/dnsapi/dns_ali.sh b/dnsapi/dns_ali.sh
index 90196c69..b8ca9169 100755
--- a/dnsapi/dns_ali.sh
+++ b/dnsapi/dns_ali.sh
@@ -18,7 +18,9 @@ Ali_DNS_API="https://alidns.aliyuncs.com/"
#Usage: dns_ali_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_ali_add() {
- fulldomain=$1
+ # the API only accepts punycode for IDN domains, and a raw UTF-8 domain
+ # also breaks the request signature (issue 4733)
+ fulldomain=$(_idn "$1")
txtvalue=$2
_prepare_ali_credentials || return 1
@@ -33,7 +35,7 @@ dns_ali_add() {
}
dns_ali_rm() {
- fulldomain=$1
+ fulldomain=$(_idn "$1")
txtvalue=$2
Ali_Key="${Ali_Key:-$(_readaccountconf_mutable Ali_Key)}"
Ali_Secret="${Ali_Secret:-$(_readaccountconf_mutable Ali_Secret)}"
@@ -69,8 +71,8 @@ _ali_rest() {
ign="$2"
mtd="${3:-GET}"
- signature=$(printf "%s" "$mtd&%2F&$(printf "%s" "$query" | _url_encode upper-hex)" | _hmac "sha1" "$(printf "%s" "$Ali_Secret&" | _hex_dump | tr -d " ")" | _base64)
- signature=$(printf "%s" "$signature" | _url_encode upper-hex)
+ signature=$(printf "%s" "$mtd&%2F&$(printf "%s" "$query" | _ali_urlencode_upper)" | _hmac "sha1" "$(printf "%s" "$Ali_Secret&" | _hex_dump | tr -d " ")" | _base64)
+ signature=$(printf "%s" "$signature" | _ali_urlencode_upper)
url="$endpoint?Signature=$signature"
if [ "$mtd" = "GET" ]; then
@@ -96,6 +98,20 @@ _ali_rest() {
fi
}
+# stdin stdout
+# The Aliyun signature requires percent-encoding with upper-case hex.
+# Do not use "_url_encode upper-hex" here: this file is also bundled by
+# third parties (e.g. Proxmox VE proxmox-acme) whose older copies of the
+# acme.sh function library ignore the upper-hex argument and output
+# lower-case hex, which invalidates the signature.
+# https://github.com/acmesh-official/acme.sh/issues/6272
+_ali_urlencode_upper() {
+ {
+ _url_encode
+ echo
+ } | sed 's/%a/%A/g;s/%b/%B/g;s/%c/%C/g;s/%d/%D/g;s/%e/%E/g;s/%f/%F/g;s/%\(.\)a/%\1A/g;s/%\(.\)b/%\1B/g;s/%\(.\)c/%\1C/g;s/%\(.\)d/%\1D/g;s/%\(.\)e/%\1E/g;s/%\(.\)f/%\1F/g'
+}
+
_ali_nonce() {
if [ "$ACME_OPENSSL_BIN" ]; then
"$ACME_OPENSSL_BIN" rand -hex 16 2>/dev/null && return 0
diff --git a/dnsapi/dns_arubabusiness.sh b/dnsapi/dns_arubabusiness.sh
new file mode 100644
index 00000000..90b3f18d
--- /dev/null
+++ b/dnsapi/dns_arubabusiness.sh
@@ -0,0 +1,490 @@
+#!/usr/bin/env sh
+
+# shellcheck disable=SC2034
+dns_arubabusiness_info='ArubaBusiness
+Site: business.aruba.it
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_arubabusiness
+Options:
+ AB_Key Your ArubaBusiness API Key
+ AB_User Your account user
+ AB_Pass Your account password
+'
+
+#
+# A word of warning: as of this writing, api.arubabusiness.it only supports oauth authentication using the "password" grant type.
+# If you are REALLY sure you want to use it, it would be wise set up a dedicated technical user without administrative privileges
+#
+
+ARUBABUSINESS_API='https://api.arubabusiness.it'
+
+######## Public functions ########
+
+#
+# Usage: dns_arubabusiness_add _acme-challenge.www.domain.com aaaabbbbcccc111122223333
+#
+# Add a new TXT record whose name and value match the given domain and value
+#
+# Variables
+# _full_domain: $1 - the name of the TXT record
+# _txt_value: $2 - the value of the TXT record
+# _body
+# dns_details
+# domain_id
+# dns_record_id
+# response
+#
+dns_arubabusiness_add() {
+ _full_domain=$1
+ _txt_value=$2
+
+ if ! _ab_authenticate; then
+ return 1
+ fi
+
+ if ! _ab_domain_id "$_full_domain"; then
+ return 1
+ fi
+
+ if _ab_dns_record_id "$_full_domain" "$_txt_value" "$dns_details"; then
+ # This is very unlikely, but allow the process to use the existing record
+ _info "A TXT record with name: $_full_domain and value: $_txt_value already exists (id: $dns_record_id)"
+ return 0
+ fi
+
+ _body="{ \"IdDomain\": $domain_id, \"Type\": \"TXT\", \"Name\": \"$_full_domain\", \"Content\": \"\\\"$_txt_value\\\"\" }"
+
+ _debug "Adding TXT record with name: $_full_domain and value: $_txt_value"
+
+ if ! _ab_rest POST "api/domains/dns/record" "$_body" || ! _contains "$response" "DomainId"; then
+ _err "Failed to add TXT record with name: $_full_domain"
+ return 1
+ fi
+
+ _info "Sleeping 10 seconds to let ArubaBusiness do its magic"
+ _sleep 10
+
+ # Refresh dns details and check that the record was really added
+ if ! _ab_dns_details "$root_domain"; then
+ return 1
+ fi
+
+ if ! _ab_dns_record_id "$_full_domain" "$_txt_value" "$dns_details"; then
+ # This should never happen
+ _err "The TXT record with name: $_full_domain was not set"
+ _err "Please check that the dns records are clean"
+ return 1
+ fi
+
+ _info "Added TXT record with id: $dns_record_id"
+ return 0
+}
+
+#
+# Usage: dns_arubabusiness_rm _acme-challenge.www.domain.com aaaabbbbcccc111122223333
+#
+# Remove the TXT record whose name and value match the given domain and value
+#
+# Variables
+# _full_domain: $1 - the name of the TXT record
+# _txt_value: $2 - the value of the TXT record
+# dns_details
+# dns_record_id
+#
+dns_arubabusiness_rm() {
+ _full_domain=$1
+ _txt_value=$2
+
+ if ! _ab_authenticate; then
+ return 1
+ fi
+
+ if ! _ab_domain_id "$_full_domain"; then
+ return 1
+ fi
+
+ if ! _ab_dns_record_id "$_full_domain" "$_txt_value" "$dns_details" || [ -z "$dns_record_id" ]; then
+ _err "Could not retrieve the record id for: $_full_domain"
+ return 1
+ fi
+
+ _debug "Deleting TXT record: $dns_record_id"
+ if ! _ab_rest DELETE "api/domains/dns/record/$dns_record_id" || ! _contains "$response" "DomainId"; then
+ _err "Failed to delete TXT record: $dns_record_id"
+ return 1
+ fi
+
+ _info "Deleted TXT record: $dns_record_id"
+ return 0
+}
+
+######## Private functions ########
+
+#
+# Usage: _ab_domain_id _acme-challenge.www.domain.com
+#
+# Split the input domain into subdomain + root domain and get the id of the root domain
+#
+# Variables
+# _full_domain: $1 - the domain whose root needs to be extracted
+# _domain_sections
+# _current_index
+# _candidate_subdomain
+# _candidate_domain
+# sub_domain
+# root_domain
+# domain_id
+# dns_details: a json containing all dns records registered on the root domain
+#
+# Example
+# _get_root _acme-challenge.www.domain.com
+#
+# Should return
+# sub_domain=_acme-challenge.www
+# root_domain=domain.com
+# domain_id=123123123123
+# dns_details="{JSON_CONTENT}"
+#
+_ab_domain_id() {
+ _full_domain=$1
+
+ _info "Attempting to retrieve root domain details for: $_full_domain"
+
+ _domain_sections=$(_math "$(printf "%s" "$_full_domain" | tr '.' '\n' | wc -l)" + 1)
+
+ if [ "$_domain_sections" -lt 1 ]; then
+ _err "Invalid input $_full_domain"
+ return 1
+ fi
+
+ _current_index=1
+ while true; do
+ _candidate_subdomain=$(if [ "$_current_index" = "1" ]; then printf ""; else printf "%s" "$_full_domain" | cut -d . -f 1-"$(_math "$_current_index" - 1)"; fi)
+ _candidate_domain=$(printf "%s" "$_full_domain" | cut -d . -f "$_current_index"-"$_domain_sections")
+
+ if ! _ab_dns_details "$_candidate_domain"; then
+ _debug2 "Could not fetch dns details for: $_candidate_domain"
+ _current_index=$(_math "$_current_index" + 1)
+
+ # Fail if there are no candidates left
+ if [ "$_current_index" -gt "$_domain_sections" ]; then
+ _err "Could not determine the root domain for: $_full_domain"
+ return 1
+ fi
+ else
+ sub_domain="$_candidate_subdomain"
+ root_domain="$_candidate_domain"
+ # Extract the domain id, which is an integer and contains no commas
+ domain_id="$(printf "%s" "$dns_details" | _egrep_o '"Id":[^,]*' | _head_n 1 | cut -d : -f 2 | tr -d ' "')"
+
+ if [ -z "$domain_id" ]; then
+ _err "Could not determine the domain id for: $root_domain"
+ return 1
+ fi
+
+ _debug "Retrieved root domain id: $domain_id"
+ return 0
+ fi
+ done
+}
+
+#
+# Usage: _ab_dns_record_id _acme-challenge.www.domain.com "aaaabbbbcccc111122223333" "{JSON_CONTENT}"
+#
+# Extract the record id of the first TXT record whose name and content match the input values
+#
+# Variables
+# _record_name: $1
+# _txt_value: $2
+# _dns_details: $3 - the json returned by a previous call to '_ab_dns_details() $root_domain'
+# _record_ids
+# _record_names
+# _record_types
+# _record_contents
+# _record_ids_count
+# _record_names_count
+# _record_types_count
+# _record_contents_count
+# _i
+# dns_record_id
+#
+# Notes
+# TXT correspond to record type 5
+# ArubaBusiness appends a terminating dot (.) to the record name
+# The content field may contain the following character sequence: \"
+# All record names are always converted to lowercase
+#
+_ab_dns_record_id() {
+ _record_name=$1
+ _txt_value=$2
+ _dns_details=$3
+
+ _record_name_lowercase=$(printf "%s" "$_record_name" | _lower_case)
+
+ # Extract the record ids, which are integers and contain no commas, colons or spaces
+ # The first id is skipped because it refers to the domain id
+ _record_ids=$(printf "%s" "$_dns_details" | sed 's/"Id":/\n"Id":/g' | _egrep_o '"Id":[^,]*' | _tail_n +2 | cut -d : -f 2 | tr -d ' ' | tr '\n' ' ')
+
+ # Extract the record names, which are strings but cannot contain commas, colons, spaces and quotes
+ # The first name is skipped because it refers to the domain name
+ _record_names=$(printf "%s" "$_dns_details" | sed 's/"Name":/\n"Name":/g' | _egrep_o '"Name":[^,]*' | _tail_n +2 | cut -d : -f 2 | tr -d ' "' | tr '\n' ' ')
+
+ # Extract the record types, which are integers (except for the first one) and contain no commas, colons or spaces
+ # The first type is skipped because it refers to the domain type
+ _record_types=$(printf "%s" "$_dns_details" | sed 's/"Type":/\n"Type":/g' | _egrep_o '"Type":[^,]*' | _tail_n +2 | cut -d : -f 2 | tr -d ' ' | tr '\n' ' ')
+
+ # Extract the record contents, which are strings and may contain no quotes except for TXT records, which must be delimited by two \" literals
+ # Note: There is no domain related entry here
+ # Note: A " character is appended at the end of each content to make it easier to process the list later
+ _record_contents=$(printf "%s" "$_dns_details" | sed 's/"Content":/\n"Content":/g' | sed 's/\\"//g' | _egrep_o '"Content": *"[^"]*"' | cut -d : -f 2- | sed -n 's/"\(.*\)"/\1/p' | tr '\n' '#')
+
+ _info "IDS: $_record_ids"
+ _info "NAMES: $_record_names"
+ _info "TYPEs: $_record_types"
+ _info "CONTENTS: $_record_contents"
+
+ _record_ids_count=$(printf "%s" "$_record_ids" | tr ' ' '\n' | wc -l)
+ _record_names_count=$(printf "%s" "$_record_names" | tr ' ' '\n' | wc -l)
+ _record_types_count=$(printf "%s" "$_record_types" | tr ' ' '\n' | wc -l)
+ _record_contents_count=$(printf "%s" "$_record_contents" | tr '#' '\n' | wc -l)
+
+ _info "Ids: $_record_ids_count, names: $_record_names_count, types: $_record_types_count, contents: $_record_contents_count"
+
+ if [ "$_record_ids_count" != "$_record_names_count" ] || [ "$_record_ids_count" != "$_record_types_count" ] || [ "$_record_ids_count" != "$_record_contents_count" ]; then
+ _err "Failed to parse record elements. Ids: $_record_ids_count, names: $_record_names_count, types: $_record_types_count, contents: $_record_contents_count"
+ return 1
+ fi
+
+ _info "Looking for a TXT record matching inputs - name: $_record_name_lowercase value: $_txt_value"
+
+ _i=1
+ while [ "$_i" -le "$_record_ids_count" ]; do
+ _current_name=$(printf "%s" "$_record_names" | cut -d " " -f "$_i")
+ _current_type=$(printf "%s" "$_record_types" | cut -d " " -f "$_i")
+ _current_content=$(printf "%s" "$_record_contents" | cut -d "#" -f "$_i")
+
+ if [ "$_record_name_lowercase." = "$_current_name" ] && [ "5" = "$_current_type" ] && [ "$_txt_value" = "$_current_content" ]; then
+ dns_record_id=$(printf "%s" "$_record_ids" | cut -d " " -f "$_i")
+ _info "Found matching record with id: $dns_record_id"
+ return 0
+ else
+ _debug2 "Record does not match - type: '$_current_type' name: '$_current_name' value: '$_current_content'; Expected '$_record_name_lowercase.' '5' '$_txt_value'"
+ fi
+ _i=$(_math "$_i" + 1)
+ done
+
+ _debug2 "No matching record was found in $_dns_details"
+ return 1
+}
+
+#
+# Usage: _ab_dns_details domain.com
+#
+# Retrieve dns info for the given input domain
+#
+# Variables
+# _domain: $1
+# dns_details: the json returned by the call to $ARUBABUSINESS_API/api/domains/dns/$_domain/details (if return status is 0)
+# response
+#
+_ab_dns_details() {
+ _domain=$1
+
+ if ! _ab_rest GET "api/domains/dns/$_domain/details" || ! _contains "$response" "DomainId"; then
+ return 1
+ fi
+
+ dns_details="$response"
+ return 0
+}
+
+#
+# Usage: _ab_authenticate
+#
+# Read account conf, update domain conf and perform user authentication to acquire an access token
+#
+# Variables
+# AB_Key
+# AB_User
+# AB_Pass
+# AB_Token
+#
+_ab_authenticate() {
+ AB_Key="${AB_Key:-$(_readaccountconf_mutable AB_Key)}"
+ AB_User="${AB_User:-$(_readaccountconf_mutable AB_User)}"
+ AB_Pass="${AB_Pass:-$(_readaccountconf_mutable AB_Pass)}"
+
+ if [ -z "$AB_Key" ] || [ -z "$AB_User" ] || [ -z "$AB_Pass" ]; then
+ AB_Key=""
+ AB_User=""
+ AB_Pass=""
+ _err "Either the ArubaBusiness API key, the user or the password has not been defined yet."
+ _err "Please configure them and try again."
+ return 1
+ fi
+
+ _saveaccountconf_mutable AB_Key "$AB_Key"
+ _saveaccountconf_mutable AB_User "$AB_User"
+ _saveaccountconf_mutable AB_Pass "$AB_Pass"
+
+ if ! _ab_get_token || [ -z "$AB_Token" ]; then
+ _err "Failed to acquire an access token"
+ return 1
+ fi
+
+ return 0
+}
+
+#
+# Usage: _ab_get_token
+#
+# Try acquiring a temporary access token. The token should have a 24h lifespan
+#
+# Variables
+# _ab_user_enc
+# _ab_pass_enc
+# _ab_authdata
+# AB_User
+# AB_Pass
+# AB_Token
+# response
+# _H2
+#
+_ab_get_token() {
+ _ab_user_enc=$(printf "%s" "$AB_User" | _url_encode)
+ _ab_pass_enc=$(printf "%s" "$AB_Pass" | _url_encode)
+ _ab_authdata="grant_type=password&username=$_ab_user_enc&password=$_ab_pass_enc"
+
+ _H2="Content-Type: application/x-www-form-urlencoded"
+
+ if ! _ab_rest POST "auth/token" "$_ab_authdata" || ! _contains "$response" "access_token"; then
+ _err "Authentication failure"
+ return 1
+ fi
+
+ AB_Token="$(printf "%s" "$response" | _egrep_o '"access_token":"[^\"]*"' | cut -d : -f 2 | tr -d '"')"
+
+ if [ -z "$AB_Token" ]; then
+ _err "Could not extract access token"
+ return 1
+ fi
+
+ _debug "Acquired access token"
+ return 0
+}
+
+#
+# Usage: _ab_rest POST "example/endpoint" "password=123"
+#
+# Perform a REST request using the given method, endpoint and data
+#
+# Variables
+# _method: $1 - The http method
+# _endpoint: $2 - The api path (relative to $ARUBABUSINESS_API)
+# _data: $3 - The body of the request (optional)
+# _key_trimmed
+# _token_trimmed
+# _ret_code
+# AB_Key
+# AB_Token
+# ARUBABUSINESS_API
+# _H1
+# _H2
+# _H3
+# _H4
+#
+_ab_rest() {
+ _method=$1
+ _endpoint="$2"
+ _data="$3"
+
+ _key_trimmed=$(printf "%s" "$AB_Key" | tr -d '"')
+ _token_trimmed=$(printf "%s" "$AB_Token" | tr -d '"')
+
+ _H1="Accept: application/json"
+
+ if [ -z "$_H2" ]; then
+ # Default to application/json
+ _H2="Content-Type: application/json"
+ fi
+
+ if [ "$_key_trimmed" ]; then
+ _H3="Authorization-Key: $_key_trimmed"
+ else
+ _err "Missing Api Key"
+ _ab_cleanup_headers
+ return 1
+ fi
+
+ if [ "$_token_trimmed" ]; then
+ _H4="Authorization: Bearer $_token_trimmed"
+ else
+ _debug "No access token set"
+ fi
+
+ if [ "$_method" != "GET" ]; then
+ response="$(_post "$_data" "$ARUBABUSINESS_API/$_endpoint" "" "$_method")"
+ else
+ response="$(_get "$ARUBABUSINESS_API/$_endpoint")"
+ fi
+
+ _ret_code=$?
+
+ if [ "$_ret_code" = "0" ] && _ab_call_is_success; then
+ # Normalize the json response
+ response="$(printf "%s" "$response" | _normalizeJson)"
+ _ret_code=0
+ else
+ _err "Failed to call endpoint: $_endpoint"
+ _ret_code=1
+ fi
+
+ _ab_cleanup_headers
+
+ return $_ret_code
+}
+
+#
+# Usage: _ab_cleanup_headers
+#
+# Unset header variables to avoid interfering with other calls
+#
+# Variables
+# _H1
+# _H2
+# _H3
+# _H4
+#
+_ab_cleanup_headers() {
+ # Cleanup request headers
+ unset _H1 _H2 _H3 _H4 _H5
+
+ # Cleanup response headers
+ if [ -f "$HTTP_HEADER" ]; then
+ : >"$HTTP_HEADER"
+ fi
+}
+
+#
+# Usage: _ab_call_is_success
+#
+# Check whether a call's response http status is one of 200, 201, 202 or 204 (other 2xx are not handled)
+#
+# Variables
+# _status
+# _http_status
+# _success_http_codes
+# HTTP_HEADER
+#
+_ab_call_is_success() {
+ _success_http_codes="200 201 202 204"
+ if [ -f "$HTTP_HEADER" ]; then
+ _http_status=$(_egrep_o "^HTTP[\/0-9. ]*" <"$HTTP_HEADER" | _head_n 1 | cut -d " " -f 2)
+ for _status in $_success_http_codes; do
+ if [ "$_status" = "$_http_status" ]; then
+ return 0
+ fi
+ done
+ fi
+
+ return 1
+}
diff --git a/dnsapi/dns_autodns.sh b/dnsapi/dns_autodns.sh
index ce566978..e26d699b 100644
--- a/dnsapi/dns_autodns.sh
+++ b/dnsapi/dns_autodns.sh
@@ -139,12 +139,21 @@ _get_autodns_zone() {
return 1
}
+# Escape the XML special characters (& < > ' ") so that credentials
+# containing them do not break the request document (issue 5317).
+_autodns_xml_encode() {
+ sed "s/&/\&/g;s/\</g;s/>/\>/g;s/'/\'/g;s/\"/\"/g"
+}
+
_build_request_auth_xml() {
+ _autodns_user_xml="$(printf "%s" "$AUTODNS_USER" | _autodns_xml_encode)"
+ _autodns_password_xml="$(printf "%s" "$AUTODNS_PASSWORD" | _autodns_xml_encode)"
+ _autodns_context_xml="$(printf "%s" "$AUTODNS_CONTEXT" | _autodns_xml_encode)"
printf "%s%s%s
- " "$AUTODNS_USER" "$AUTODNS_PASSWORD" "$AUTODNS_CONTEXT"
+ " "$_autodns_user_xml" "$_autodns_password_xml" "$_autodns_context_xml"
}
# Arguments:
diff --git a/dnsapi/dns_aws.sh b/dnsapi/dns_aws.sh
index b76d69c2..1face1c8 100755
--- a/dnsapi/dns_aws.sh
+++ b/dnsapi/dns_aws.sh
@@ -11,7 +11,8 @@ Options:
# All `_sleep` commands are included to avoid Route53 throttling, see
# https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests
-AWS_HOST="route53.amazonaws.com"
+# Updated from "route53.amazonaws.com"
+AWS_HOST="route53.global.api.aws"
AWS_URL="https://$AWS_HOST"
AWS_WIKI="https://github.com/acmesh-official/acme.sh/wiki/How-to-use-Amazon-Route53-API"
diff --git a/dnsapi/dns_baidu.sh b/dnsapi/dns_baidu.sh
index 8651deab..dfad8eeb 100644
--- a/dnsapi/dns_baidu.sh
+++ b/dnsapi/dns_baidu.sh
@@ -49,26 +49,95 @@ Options:
Baidu_SK SecretAccessKey
OptionsAlt:
Baidu_BCD_Host API host, default: bcd.baidubce.com
+ Baidu_DNS_Host New DNS API host, default: dns.baidubce.com
+ Baidu_API_Preference Engine preference, default: auto
Baidu_BCD_Version API version number, default: 1
Baidu_BCD_Expire Signature expiration seconds, default: 3600
Baidu_View Resolve view, default: DEFAULT
+ Baidu_Line New DNS line, default: default
Baidu_TTL Resolve ttl seconds, default: 300
Baidu_RM_Max Max records to delete in one run, default: 20
'
BAIDU_BCD_DEFAULT_HOST="bcd.baidubce.com"
+BAIDU_DNS_DEFAULT_HOST="dns.baidubce.com"
# --- Public API ---
dns_baidu_add() {
fulldomain=$(_idn "$1")
txtvalue=$2
- if ! _baidu_prepare_record "$fulldomain"; then
- _baidu_err "baidu_prepare_record failed for add: $fulldomain"
+ if ! _baidu_run_with_fallback "add" "$fulldomain" "$txtvalue"; then
+ _baidu_err "all baidu api engines failed for add: $fulldomain"
return 1
fi
- if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$txtvalue"; then
+ return 0
+}
+
+dns_baidu_rm() {
+ fulldomain=$(_idn "$1")
+ txtvalue=$2
+
+ if ! _baidu_run_with_fallback "rm" "$fulldomain" "$txtvalue"; then
+ _baidu_err "all baidu api engines failed for delete: $fulldomain"
+ return 1
+ fi
+
+ return 0
+}
+
+_baidu_run_with_fallback() {
+ _action="$1"
+ _fulldomain="$2"
+ _txtvalue="$3"
+
+ if ! _baidu_load_credentials; then
+ _baidu_err "baidu_load_credentials failed"
+ return 1
+ fi
+
+ for _baidu_api_engine in $(_baidu_engine_order); do
+ if ! _baidu_prepare_record "$_fulldomain"; then
+ _baidu_info "prepare failed for engine: $_baidu_api_engine"
+ continue
+ fi
+
+ if [ "$_action" = "add" ]; then
+ if _baidu_add_record "$_txtvalue"; then
+ return 0
+ fi
+ else
+ if _baidu_rm_record "$_txtvalue"; then
+ return 0
+ fi
+ fi
+
+ _baidu_info "engine failed, try next if available: $_baidu_api_engine"
+ done
+
+ return 1
+}
+
+_baidu_engine_order() {
+ _pref="$(_lower_case "$(_baidu_trim_ws "${Baidu_API_Preference:-auto}")")"
+ case "$_pref" in
+ legacy)
+ printf "%s" "legacy new"
+ ;;
+ new)
+ printf "%s" "new legacy"
+ ;;
+ *)
+ printf "%s" "new legacy"
+ ;;
+ esac
+}
+
+_baidu_add_record() {
+ _txtvalue="$1"
+
+ if ! _baidu_find_record_ids_current "$_zone_name" "$_record_domain" "TXT" "$_txtvalue"; then
_baidu_err "baidu_find_record_ids failed for add: $_record_domain.$_zone_name"
return 1
fi
@@ -85,16 +154,28 @@ dns_baidu_add() {
_ttl="300"
;;
esac
- _view="$(_baidu_trim_ws "${Baidu_View:-DEFAULT}")"
- txtvalue="$(_baidu_trim_ws "$txtvalue")"
+
+ txtvalue="$(_baidu_trim_ws "$_txtvalue")"
_record_domain="$(_baidu_trim_ws "$_record_domain")"
_zone_name="$(_baidu_trim_ws "$_zone_name")"
- _body="$(_baidu_payload_add_txt "$_zone_name" "$_record_domain" "$txtvalue" "$_ttl" "$_view")"
-
- if ! _baidu_bcd_post "/domain/resolve/add" "$_body"; then
- _baidu_err "baidu_bcd_post failed: add record"
- return 1
+ if [ "$_baidu_api_engine" = "new" ]; then
+ _line="$(_baidu_trim_ws "${Baidu_Line:-default}")"
+ if [ -z "$_line" ]; then
+ _line="default"
+ fi
+ _body="$(_baidu_payload_add_txt_dns "$_record_domain" "$txtvalue" "$_ttl" "$_line")"
+ if ! _baidu_dns_call "POST" "/v1/dns/zone/${_zone_name}/record" "$_body"; then
+ _baidu_err "baidu_dns_call failed: add record"
+ return 1
+ fi
+ else
+ _view="$(_baidu_trim_ws "${Baidu_View:-DEFAULT}")"
+ _body="$(_baidu_payload_add_txt "$_zone_name" "$_record_domain" "$txtvalue" "$_ttl" "$_view")"
+ if ! _baidu_bcd_post "/domain/resolve/add" "$_body"; then
+ _baidu_err "baidu_bcd_post failed: add record"
+ return 1
+ fi
fi
if _baidu_is_api_error "$response"; then
@@ -105,16 +186,10 @@ dns_baidu_add() {
return 0
}
-dns_baidu_rm() {
- fulldomain=$(_idn "$1")
- txtvalue=$2
+_baidu_rm_record() {
+ _txtvalue="$1"
- if ! _baidu_prepare_record "$fulldomain"; then
- _baidu_err "baidu_prepare_record failed for delete: $fulldomain"
- return 1
- fi
-
- if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$txtvalue"; then
+ if ! _baidu_find_record_ids_current "$_zone_name" "$_record_domain" "TXT" "$_txtvalue"; then
_baidu_err "baidu_find_record_ids failed for delete: $_record_domain.$_zone_name"
return 1
fi
@@ -138,28 +213,37 @@ dns_baidu_rm() {
fi
for _rid in $_ids; do
- _body="$(_baidu_payload_delete "$_zone_name" "$_rid")"
- if ! _baidu_bcd_post "/domain/resolve/delete" "$_body"; then
- _baidu_err "baidu_bcd_post failed: delete recordId=$_rid"
- return 1
- fi
- if _baidu_is_api_error "$response"; then
- _baidu_err "$response"
- return 1
+ if [ "$_baidu_api_engine" = "new" ]; then
+ if ! _baidu_dns_call "DELETE" "/v1/dns/zone/${_zone_name}/record/${_rid}" ""; then
+ _baidu_err "baidu_dns_call failed: delete recordId=$_rid"
+ return 1
+ fi
+ else
+ _body="$(_baidu_payload_delete "$_zone_name" "$_rid")"
+ if ! _baidu_bcd_post "/domain/resolve/delete" "$_body"; then
+ _baidu_err "baidu_bcd_post failed: delete recordId=$_rid"
+ return 1
+ fi
+ if _baidu_is_api_error "$response"; then
+ _baidu_err "$response"
+ return 1
+ fi
fi
done
- if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$txtvalue"; then
- _baidu_err "baidu_find_record_ids failed for delete verify: $_record_domain.$_zone_name"
- return 1
- fi
- _left_ids="$_BAIDU_FIND_RESULT"
- if [ -z "$_left_ids" ]; then
- return 0
- fi
- if [ -n "$_left_ids" ]; then
- _baidu_err "delete verification failed: $_record_domain.$_zone_name still has TXT records"
- return 1
+ if [ "$_baidu_api_engine" = "legacy" ]; then
+ if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$_txtvalue"; then
+ _baidu_err "baidu_find_record_ids failed for delete verify: $_record_domain.$_zone_name"
+ return 1
+ fi
+ _left_ids="$_BAIDU_FIND_RESULT"
+ if [ -z "$_left_ids" ]; then
+ return 0
+ fi
+ if [ -n "$_left_ids" ]; then
+ _baidu_err "delete verification failed: $_record_domain.$_zone_name still has TXT records"
+ return 1
+ fi
fi
return 0
@@ -182,6 +266,7 @@ _baidu_load_credentials() {
_saveaccountconf_mutable Baidu_SK "$Baidu_SK"
BAIDU_BCD_HOST="${Baidu_BCD_Host:-$BAIDU_BCD_DEFAULT_HOST}"
+ BAIDU_DNS_HOST="${Baidu_DNS_Host:-$BAIDU_DNS_DEFAULT_HOST}"
BAIDU_BCD_VERSION="${Baidu_BCD_Version:-1}"
return 0
@@ -189,13 +274,16 @@ _baidu_load_credentials() {
_baidu_prepare_record() {
_fulldomain="$1"
- if ! _baidu_load_credentials; then
- _baidu_err "baidu_load_credentials failed"
- return 1
- fi
- if ! _baidu_get_root "$_fulldomain"; then
- _baidu_err "Could not find zone for $_fulldomain"
- return 1
+ if [ "$_baidu_api_engine" = "new" ]; then
+ if ! _baidu_get_root_dns "$_fulldomain"; then
+ _baidu_err "Could not find zone by new dns api for $_fulldomain"
+ return 1
+ fi
+ else
+ if ! _baidu_get_root "$_fulldomain"; then
+ _baidu_err "Could not find zone by legacy bcd api for $_fulldomain"
+ return 1
+ fi
fi
_record_domain="$_sub_domain"
_zone_name="$_domain"
@@ -234,6 +322,43 @@ _baidu_get_root() {
done
}
+_baidu_get_root_dns() {
+ domain=$1
+ i=1
+ p=1
+
+ while true; do
+ h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+ if [ -z "$h" ]; then
+ _baidu_err "invalid domain: $domain"
+ return 1
+ fi
+
+ if ! _baidu_dns_call "GET" "/v1/dns/zone/${h}/record" ""; then
+ _baidu_info "baidu_dns_call failed: list zones"
+ elif ! _baidu_is_api_error "$response" && (_contains "$response" "\"records\"" || _contains "$response" "\"maxKeys\""); then
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ _domain=$h
+ if [ "$_sub_domain" = "$_domain" ]; then
+ _sub_domain="@"
+ fi
+ _baidu_info "zone matched by dns api: $_domain (host: $_sub_domain)"
+ return 0
+ fi
+
+ p=$i
+ i=$(_math "$i" + 1)
+ done
+}
+
+_baidu_find_record_ids_current() {
+ if [ "$_baidu_api_engine" = "new" ]; then
+ _baidu_find_record_ids_dns "$@"
+ else
+ _baidu_find_record_ids "$@"
+ fi
+}
+
_baidu_find_record_ids() {
_zone_name="$1"
_record_domain="$2"
@@ -293,6 +418,39 @@ EOF
_BAIDU_FIND_RESULT="$_ids"
}
+_baidu_find_record_ids_dns() {
+ _zone_name="$1"
+ _record_domain="$2"
+ _rdtype="$3"
+ _rdata="$4"
+ _BAIDU_FIND_RESULT=""
+
+ if ! _baidu_dns_call "GET" "/v1/dns/zone/${_zone_name}/record" ""; then
+ _baidu_err "baidu_dns_call failed: list records"
+ return 1
+ fi
+
+ if _baidu_is_api_error "$response"; then
+ _baidu_err "baidu_dns error: $(_baidu_json_get_str "$response" "code") $(_baidu_json_get_str "$response" "message")"
+ return 1
+ fi
+
+ _normalized="$(printf "%s" "$response" | _normalizeJson)"
+ _records=$(printf "%s" "$_normalized" | sed 's/},{/}\n{/g')
+ _ids=""
+
+ while IFS= read -r _line; do
+ _id="$(_baidu_match_record_id_dns "$_line" "$_record_domain" "$_rdtype" "$_rdata")"
+ if [ "$_id" ]; then
+ _ids="$_ids $_id"
+ fi
+ done < ${_now})"
+ return 0
+ fi
+
+ _info "ComLaude auth..."
+ _comlaude_body="{\"username\":\"$COMLAUDE_USERNAME\",\"password\":\"$COMLAUDE_PASSWORD\",\"api_key\":\"$COMLAUDE_API_KEY\"}"
+ _comlaude_response="$(_post "$_comlaude_body" "$COMLAUDE_API/api_login" "" "POST" "application/json")"
+
+ if ! _contains "$_comlaude_response" "access_token"; then
+ _err "Auth failed: $_comlaude_response"
+ return 1
+ fi
+
+ COMLAUDE_ACCESS_TOKEN=$(echo "$_comlaude_response" | _egrep_o '"access_token":"[^"]*"' | cut -d'"' -f4)
+ # store expiracy from api reply l'API ("expires_in" in seconds)
+ _comlaude_expires_in=$(echo "$_comlaude_response" | _egrep_o '"expires_in":[0-9]*' | cut -d: -f2)
+ [ -z "$_comlaude_expires_in" ] && _comlaude_expires_in=3000 # fallback if no info
+
+ COMLAUDE_TOKEN_EXPIRY=$(($(_time) + _comlaude_expires_in - 60)) # margin of 60s to secure renew
+
+ _saveaccountconf_mutable COMLAUDE_ACCESS_TOKEN "$COMLAUDE_ACCESS_TOKEN"
+ _saveaccountconf_mutable COMLAUDE_TOKEN_EXPIRY "$COMLAUDE_TOKEN_EXPIRY"
+
+ return 0
+}
+
+########## DOMAIN RESOLUTION ##########
+
+_comlaude_get_root() {
+ COMLAUDE_GROUP_ID="${COMLAUDE_GROUP_ID:-$(_readaccountconf_mutable COMLAUDE_GROUP_ID)}"
+ if [ -z "$COMLAUDE_GROUP_ID" ]; then
+ _err "Missing COMLAUDE_GROUP_ID"
+ return 1
+ fi
+
+ _comlaude_input_domain="$1"
+ _comlaude_input_domain="${_comlaude_input_domain#_acme-challenge.}"
+ case "$_comlaude_input_domain" in
+ \*.*) _comlaude_input_domain="${_comlaude_input_domain#*.}" ;;
+ esac
+
+ _debug "Normalized domain: $_comlaude_input_domain"
+
+ _comlaude_i=1
+ while true; do
+ _comlaude_d=$(printf "%s" "$_comlaude_input_domain" | cut -d . -f "$_comlaude_i-")
+ [ -z "$_comlaude_d" ] && {
+ _debug "No matching domain found for $_comlaude_input_domain"
+ return 1
+ }
+
+ # don't test unnecessary levels
+ # registered domain : TLD only (no dot after cut).
+ case "$_comlaude_d" in
+ *.*) : ;;
+ *)
+ _debug "Skipping bare TLD candidate: $_comlaude_d"
+ _comlaude_i=$((_comlaude_i + 1))
+ continue
+ ;;
+ esac
+
+ _debug "Checking domain: $_comlaude_d"
+
+ _comlaude_retry=0
+ _comlaude_max_retry=3 # to avoid network errors
+ _comlaude_DOM_ID=""
+ _comlaude_Z_ID=""
+
+ while [ "$_comlaude_retry" -lt "$_comlaude_max_retry" ]; do
+ export _H1="Authorization: Bearer $COMLAUDE_ACCESS_TOKEN"
+ _debug "Full URL: $COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/domains?filter[name]=$_comlaude_d&fields=id,name,active_zone"
+ _comlaude_response="$(_get "$COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/domains?filter[name]=$_comlaude_d&fields=id,name,active_zone")"
+ _H1=""
+
+ _debug "RAW response for $_comlaude_d (try $((_comlaude_retry + 1))): $_comlaude_response"
+
+ # If empty -> true network issue, we retry
+ if [ -z "$_comlaude_response" ]; then
+ _comlaude_retry=$((_comlaude_retry + 1))
+ [ "$_comlaude_retry" -lt "$_comlaude_max_retry" ] && sleep 2
+ continue
+ fi
+
+ # 404 -> domain not found in that level. no retry : continue
+ if echo "$_comlaude_response" | grep -q '"status_code":404'; then
+ _debug "404 for $_comlaude_d, moving to next level (not retrying)"
+ break
+ fi
+
+ # Domain missing (200 reply, data empty) -> continue
+ if echo "$_comlaude_response" | grep -q '"data":\[\]'; then
+ _debug "Empty data for $_comlaude_d, moving to next level"
+ break
+ fi
+
+ # Extraction via _egrep_o
+ _comlaude_DOM_ID="$(echo "$_comlaude_response" | _egrep_o '"id":"[^"]*"' | head -n1 | cut -d':' -f2 | tr -d '"')"
+ _comlaude_Z_ID="$(echo "$_comlaude_response" | _egrep_o '"active_zone":\{"id":"[^"]*"' | _egrep_o '"id":"[^"]*"$' | cut -d':' -f2 | tr -d '"')"
+
+ if [ -n "$_comlaude_DOM_ID" ] && [ -n "$_comlaude_Z_ID" ]; then
+ break
+ fi
+
+ # 200 reply but malformed data / noid -> retry transport
+ _comlaude_retry=$((_comlaude_retry + 1))
+ [ "$_comlaude_retry" -lt "$_comlaude_max_retry" ] && sleep 2
+ done
+
+ _debug "_comlaude_DOM_ID=$_comlaude_DOM_ID"
+ _debug "_comlaude_Z_ID=$_comlaude_Z_ID"
+
+ if [ -n "$_comlaude_DOM_ID" ] && [ -n "$_comlaude_Z_ID" ]; then
+ _comlaude_domain="$_comlaude_d"
+ _comlaude_domain_id="$_comlaude_DOM_ID"
+ _comlaude_zone_id="$_comlaude_Z_ID"
+ return 0
+ fi
+
+ _comlaude_i=$((_comlaude_i + 1))
+ done
+}
+########## ADD TXT ##########
+
+dns_comlaude_add() {
+ fulldomain="$1"
+ txtvalue="$2"
+
+ COMLAUDE_USERNAME="${COMLAUDE_USERNAME:-$(_readaccountconf_mutable COMLAUDE_USERNAME)}"
+ COMLAUDE_PASSWORD="${COMLAUDE_PASSWORD:-$(_readaccountconf_mutable COMLAUDE_PASSWORD)}"
+ COMLAUDE_API_KEY="${COMLAUDE_API_KEY:-$(_readaccountconf_mutable COMLAUDE_API_KEY)}"
+ COMLAUDE_GROUP_ID="${COMLAUDE_GROUP_ID:-$(_readaccountconf_mutable COMLAUDE_GROUP_ID)}"
+
+ if [ -z "$COMLAUDE_USERNAME" ] || [ -z "$COMLAUDE_PASSWORD" ] || [ -z "$COMLAUDE_API_KEY" ]; then
+ _err "You didn't specify ComLaude credentials (COMLAUDE_USERNAME, COMLAUDE_PASSWORD, COMLAUDE_API_KEY)."
+ return 1
+ fi
+
+ # Backup variable after validation
+ _saveaccountconf_mutable COMLAUDE_USERNAME "$COMLAUDE_USERNAME"
+ _saveaccountconf_mutable COMLAUDE_PASSWORD "$COMLAUDE_PASSWORD"
+ _saveaccountconf_mutable COMLAUDE_API_KEY "$COMLAUDE_API_KEY"
+ _saveaccountconf_mutable COMLAUDE_GROUP_ID "$COMLAUDE_GROUP_ID"
+
+ _info "Adding TXT: $fulldomain"
+ _comlaude_auth || return 1
+ _comlaude_get_root "$fulldomain" || return 1
+
+ _debug "Root: $_comlaude_domain"
+
+ _comlaude_data="{\"type\":\"TXT\",\"name\":\"$fulldomain\",\"value\":\"$txtvalue\",\"ttl\":60}"
+
+ export _H1="Authorization: Bearer $COMLAUDE_ACCESS_TOKEN"
+ export _H2="Content-Type: application/json"
+
+ _comlaude_response="$(_post "$_comlaude_data" "$COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/zones/$_comlaude_zone_id/records")"
+
+ _H1=""
+ _H2=""
+ if ! echo "$_comlaude_response" | grep -q '"id"'; then
+ _err "Failed to create TXT"
+ _debug "$_comlaude_response"
+ return 1
+ fi
+
+ return 0
+}
+
+########## REMOVE TXT ##########
+
+dns_comlaude_rm() {
+ fulldomain="$1"
+ txtvalue="$2"
+
+ COMLAUDE_USERNAME="${COMLAUDE_USERNAME:-$(_readaccountconf_mutable COMLAUDE_USERNAME)}"
+ COMLAUDE_PASSWORD="${COMLAUDE_PASSWORD:-$(_readaccountconf_mutable COMLAUDE_PASSWORD)}"
+ COMLAUDE_API_KEY="${COMLAUDE_API_KEY:-$(_readaccountconf_mutable COMLAUDE_API_KEY)}"
+ COMLAUDE_GROUP_ID="${COMLAUDE_GROUP_ID:-$(_readaccountconf_mutable COMLAUDE_GROUP_ID)}"
+
+ _info "Removing TXT: $fulldomain"
+
+ _comlaude_auth || return 1
+ _comlaude_get_root "$fulldomain" || return 1
+
+ export _H1="Authorization: Bearer $COMLAUDE_ACCESS_TOKEN"
+ _comlaude_encoded_name="$(printf '%s' "$fulldomain" | _url_encode)"
+ _comlaude_encoded_value="$(printf '%s' "$txtvalue" | _url_encode)"
+ _comlaude_url="$COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/zones/$_comlaude_zone_id/records?filter[type]=TXT&filter[name]=$_comlaude_encoded_name&filter[value]=$_comlaude_encoded_value"
+ _comlaude_response="$(_get "$_comlaude_url")"
+ _H1=""
+
+ _debug "Filtered records response: $_comlaude_response"
+
+ # first "id" top-level of reply (record itself,
+ # always on first position of each data[] object)
+ _comlaude_record_id="$(echo "$_comlaude_response" | _egrep_o '"data":\[\{"id":"[^"]*"' | _egrep_o '"[^"]*"$' | tr -d '"')"
+
+ if [ -z "$_comlaude_record_id" ]; then
+ _info "No matching TXT record found to delete for $fulldomain / $txtvalue"
+ return 0
+ fi
+
+ _debug "Deleting record $_comlaude_record_id"
+
+ export _H1="Authorization: Bearer $COMLAUDE_ACCESS_TOKEN"
+ _comlaude_del_url="$COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/zones/$_comlaude_zone_id/records/$_comlaude_record_id"
+ _comlaude_del_resp="$(_post "" "$_comlaude_del_url" "" "DELETE")"
+ _H1=""
+
+ if echo "$_comlaude_del_resp" | grep -q '"error"'; then
+ _err "Delete failed for $_comlaude_record_id"
+ _debug "$_comlaude_del_resp"
+ return 1
+ fi
+
+ _info "Deleted record $_comlaude_record_id"
+ return 0
+}
diff --git a/dnsapi/dns_cpanel.sh b/dnsapi/dns_cpanel.sh
index a6991403..6939c3f1 100755
--- a/dnsapi/dns_cpanel.sh
+++ b/dnsapi/dns_cpanel.sh
@@ -38,7 +38,7 @@ dns_cpanel_add() {
fi
# adding entry
_info "Adding the entry"
- stripped_fulldomain=$(echo "$fulldomain" | sed "s/.$_domain//")
+ stripped_fulldomain="${fulldomain%."$_domain"}"
_debug "Adding $stripped_fulldomain to $_domain zone"
_myget "json-api/cpanel?cpanel_jsonapi_apiversion=2&cpanel_jsonapi_module=ZoneEdit&cpanel_jsonapi_func=add_zone_record&domain=$_domain&name=$stripped_fulldomain&type=TXT&txtdata=$txtvalue&ttl=1"
if _successful_update; then return 0; fi
@@ -128,13 +128,27 @@ _get_root() {
_err "Primary domain list not found!"
return 1
fi
- for _domain in $_domains; do
- _debug "Checking if $fulldomain ends with $_domain"
- if (_endswith "$fulldomain" "$_domain"); then
- _debug "Root domain: $_domain"
- return 0
- fi
+ # Pick the LONGEST matching zone, dot-anchored: with both domain.tld and
+ # sub.domain.tld zones on the account, cPanel stores the record in the
+ # most specific zone, so add and rm must both resolve to that one.
+ _domain=""
+ for d in $_domains; do
+ _debug "Checking if $fulldomain ends with $d"
+ # case with quoted patterns gives an exact literal suffix match;
+ # _endswith treats the needle as a regex, so its dots would let
+ # xdomain.tld wrongly match zone domain.tld
+ case "$fulldomain" in
+ "$d" | *".$d")
+ if [ "${#d}" -gt "${#_domain}" ]; then
+ _domain="$d"
+ fi
+ ;;
+ esac
done
+ if [ -n "$_domain" ]; then
+ _debug "Root domain: $_domain"
+ return 0
+ fi
return 1
}
diff --git a/dnsapi/dns_creoline.sh b/dnsapi/dns_creoline.sh
new file mode 100644
index 00000000..f4d76f8e
--- /dev/null
+++ b/dnsapi/dns_creoline.sh
@@ -0,0 +1,181 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_creoline_info='creoline
+Site: https://www.creoline.com/de
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_creoline
+Help: https://help.creoline.com
+Options:
+ creolineApiToken
+ creolineApiSecret
+Issues: github.com/acmesh-official/acme.sh/issues/7103
+'
+
+creolineApi="https://api.creoline.com/v1"
+
+######## Public functions #####################
+
+# Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPB8"
+dns_creoline_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ creolineApiToken="${creolineApiToken:-$(_readaccountconf_mutable creolineApiToken)}"
+ creolineApiSecret="${creolineApiSecret:-$(_readaccountconf_mutable creolineApiSecret)}"
+
+ if [ -z "$creolineApiToken" ] || [ -z "$creolineApiSecret" ]; then
+ _err "Error required creoline API Token or creoline API Secret not specified."
+ _err "Please set it with the Command 'export creolineApiToken=' and 'export creolineApiSecret='."
+ return 1
+ else
+ _saveaccountconf_mutable creolineApiToken "$creolineApiToken"
+ _saveaccountconf_mutable creolineApiSecret "$creolineApiSecret"
+ fi
+
+ _debug "Detecting the root dns zone."
+ if ! _get_root "$fulldomain"; then
+ _err "Error on detecting the root dns zone."
+ return 1
+ fi
+
+ _info "Adding record"
+ if _creoline_rest POST "dns/zone/$_domain/record" "{\"type\":\"TXT\",\"host\":\"$_sub_domain\",\"record\":\"$txtvalue\",\"ttl\":\"60\"}"; then
+ if _contains "$response" "$txtvalue"; then
+ _info "Added, OK"
+ return 0
+ else
+ _err "Add txt record error."
+ return 1
+ fi
+ fi
+ _err "Add txt record error."
+ return 1
+}
+
+#fulldomain txtvalue
+dns_creoline_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ creolineApiToken="${creolineApiToken:-$(_readaccountconf_mutable creolineApiToken)}"
+ creolineApiSecret="${creolineApiSecret:-$(_readaccountconf_mutable creolineApiSecret)}"
+
+ _debug "Detecting the root dns zone."
+ if ! _get_root "$fulldomain"; then
+ _err "Error on detecting the root dns zone."
+ return 1
+ fi
+
+ _info "Getting earlier created txt record."
+ if ! _creoline_rest GET "dns/zone/$_domain/record/type/TXT/record/$txtvalue"; then
+ if _contains "$response" "errors" || _contains "$response" "message"; then
+ _err "Error on getting earlier created txt record."
+ return 1
+ fi
+ _err "Error on getting earlier created txt record."
+ return 1
+ fi
+
+ record_id=$(echo "$response" | _egrep_o "\"id\"[ ]*:[ ]*[0-9]+" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
+ _debug "record_id" "$record_id"
+
+ if [ -z "$record_id" ]; then
+ _err "Error on deleting earlier created txt record. No record id found in response."
+ return 1
+ fi
+
+ _info "Deleting earlier created txt record."
+ if ! _creoline_rest DELETE "dns/zone/$_domain/record/$record_id"; then
+ if _contains "$response" "errors" || _contains "$response" "message"; then
+ _err "Error on deleting earlier created txt record."
+ return 1
+ fi
+ _err "Error on deleting earlier created txt record."
+ return 1
+ fi
+
+ _info "Deleted, OK"
+ return 0
+}
+
+#################### Private functions below ##################################
+#_acme-challenge.www.domain.com
+#returns
+# _sub_domain=_acme-challenge.www
+# _domain=domain.com
+_get_root() {
+ domain=$1
+ if ! _creoline_rest GET "dns/zone/root/$domain"; then
+ return 1
+ fi
+
+ _sub_domain=$(echo "$response" | _egrep_o "\"subDomain\"[ ]*:[ ]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
+ _debug _sub_domain "$_sub_domain"
+
+ _domain=$(echo "$response" | _egrep_o "\"domain\"[ ]*:[ ]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
+ _debug _domain "$_domain"
+
+ if [ -z "$_domain" ] || [ -z "$_sub_domain" ]; then
+ return 1
+ fi
+}
+
+_creoline_rest() {
+ method=$1
+ uri="$2"
+ data="$3"
+ timestamp=$(_time)
+ canonical_request="${timestamp}.${creolineApi}/${uri}"
+ signature_hash=$(printf "%s" "$canonical_request" | _hmac sha256 "$(printf "%s" "$creolineApiSecret" | _hex_dump | tr -d " ")" hex)
+
+ _debug method "$method"
+ _debug uri "$uri"
+ _debug data "$data"
+
+ _debug2 timestamp "$timestamp"
+ _debug2 canonical_request "$canonical_request"
+ _debug2 signature_hash "$signature_hash"
+
+ token_trimmed=$(echo "$creolineApiToken" | tr -d '"')
+ hmac_trimmed=$(echo "$signature_hash" | tr -d '"')
+
+ export _H1="Content-Type: application/json"
+
+ if [ "$token_trimmed" ]; then
+ export _H2="X-Api-Token: $token_trimmed"
+ fi
+
+ if [ "$hmac_trimmed" ]; then
+ export _H3="X-Creoline-Api-Signature: $hmac_trimmed"
+ fi
+
+ if [ "$timestamp" ]; then
+ export _H4="X-Creoline-Api-Timestamp: $timestamp"
+ fi
+
+ if [ "$method" != "GET" ]; then
+ response="$(_post "$data" "$creolineApi/$uri" "" "$method")"
+ else
+ response="$(_get "$creolineApi/$uri")"
+ fi
+
+ if [ "$?" != "0" ]; then
+ _err "error $uri"
+ return 1
+ fi
+
+ _debug response "$response"
+
+ if _contains "$response" "errors"; then
+ error=$(echo "$response" | _egrep_o "\"errors\":[[]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | tr -d "[")
+ _err "Error: $error"
+ _err "URI:$uri"
+ return 1
+ elif _contains "$response" "message"; then
+ message=$(echo "$response" | _egrep_o "\"message\"[ ]*:[ ]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \")
+ _err "Error: $message"
+ _err "URI:$uri"
+ return 1
+ fi
+
+ return 0
+}
diff --git a/dnsapi/dns_cyon.sh b/dnsapi/dns_cyon.sh
index d4b6b6e8..6677b32f 100644
--- a/dnsapi/dns_cyon.sh
+++ b/dnsapi/dns_cyon.sh
@@ -285,15 +285,15 @@ _cyon_delete_txt() {
list_txt_url="https://my.cyon.ch/domain/dnseditor/list-async"
- list_txt_response="$(_get "${list_txt_url}" | sed -e 's/data-hash/\\ndata-hash/g')"
+ list_txt_response="$(_get "${list_txt_url}")"
_debug list_txt_response "${list_txt_response}"
if ! _cyon_check_if_2fa_missed "${list_txt_response}"; then return 1; fi
# Find and delete all acme challenge entries for the $fulldomain.
- _dns_entries="$(printf "%b\n" "${list_txt_response}" | sed -n 's/data-hash=\\"\([^"]*\)\\" data-identifier=\\"\([^"]*\)\\".*/\1 \2/p')"
+ _dns_entries="$(printf "%s\n" "${list_txt_response}" | _egrep_o 'data-hash=\\"[^"]*\\" data-identifier=\\"[^"]*\\"' | sed 's/data-hash=\\"\([^"]*\)\\" data-identifier=\\"\([^"]*\)\\"/\1 \2/')"
- printf "%s" "${_dns_entries}" | while read -r _hash _identifier; do
+ printf "%s\n" "${_dns_entries}" | while read -r _hash _identifier; do
dns_type="$(printf "%s" "$_identifier" | cut -d'|' -f1)"
dns_domain="$(printf "%s" "$_identifier" | cut -d'|' -f2)"
diff --git a/dnsapi/dns_czechia.sh b/dnsapi/dns_czechia.sh
index f0f4c32e..6ad60442 100644
--- a/dnsapi/dns_czechia.sh
+++ b/dnsapi/dns_czechia.sh
@@ -30,8 +30,9 @@ dns_czechia_add() {
return 1
fi
- _cz=$(printf "%s" "$_current_zone" | _lower_case | sed 's/[[:space:]]//g; s/\.$//')
- _tk=$(printf "%s" "$CZ_AuthorizationToken" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
+ _czechia_tab="$(printf '\t')"
+ _cz=$(printf "%s" "$_current_zone" | _lower_case | sed "s/[ $_czechia_tab]//g; s/\.\$//")
+ _tk=$(printf "%s" "$CZ_AuthorizationToken" | sed "s/^[ $_czechia_tab]*//; s/[ $_czechia_tab]*\$//")
if [ -z "$_cz" ] || [ -z "$_tk" ]; then
_err "Missing zone or CZ_AuthorizationToken."
@@ -76,7 +77,7 @@ dns_czechia_add() {
return 0
fi
- _nres="$(_normalizeJson "$_res")"
+ _nres="$(printf '%s' "$_res" | _normalizeJson)"
if [ "$?" -ne 0 ] || [ -z "$_nres" ]; then
_nres="$_res"
fi
@@ -108,8 +109,9 @@ dns_czechia_rm() {
return 1
fi
- _cz=$(printf "%s" "$_current_zone" | _lower_case | sed 's/[[:space:]]//g; s/\.$//')
- _tk=$(printf "%s" "$CZ_AuthorizationToken" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
+ _czechia_tab="$(printf '\t')"
+ _cz=$(printf "%s" "$_current_zone" | _lower_case | sed "s/[ $_czechia_tab]//g; s/\.\$//")
+ _tk=$(printf "%s" "$CZ_AuthorizationToken" | sed "s/^[ $_czechia_tab]*//; s/[ $_czechia_tab]*\$//")
if [ -z "$_cz" ] || [ -z "$_tk" ]; then
_err "Missing zone or CZ_AuthorizationToken."
@@ -180,12 +182,13 @@ _czechia_load_conf() {
}
_czechia_pick_zone() {
+ _czechia_pz_tab="$(printf '\t')"
_fd=$(printf "%s" "$1" | _lower_case | sed 's/\.$//')
_best_zone=""
_zones_space=$(printf "%s" "$CZ_Zones" | sed 's/,/ /g')
for _z in $_zones_space; do
- _clean_z=$(printf "%s" "$_z" | _lower_case | sed 's/[[:space:]]//g; s/\.$//')
+ _clean_z=$(printf "%s" "$_z" | _lower_case | sed "s/[ $_czechia_pz_tab]//g; s/\.\$//")
[ -z "$_clean_z" ] && continue
case "$_fd" in
diff --git a/dnsapi/dns_da.sh b/dnsapi/dns_da.sh
index 36251b05..d9cf6247 100755
--- a/dnsapi/dns_da.sh
+++ b/dnsapi/dns_da.sh
@@ -4,7 +4,7 @@ dns_da_info='DirectAdmin Server API
Site: DirectAdmin.com/api.php
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_da
Options:
- DA_Api API Server URL. E.g. "https://remoteUser:remotePassword@da.domain.tld:8443"
+ DA_Api API Server URL. E.g. "https://remoteUser:remotePassword@da.domain.tld:8443". Special characters in the user/password must be percent-encoded, e.g. "@" -> "%40".
DA_Api_Insecure Insecure TLS. 0: check for cert validity, 1: always accept
Issues: github.com/TigerP/acme.sh/issues
'
diff --git a/dnsapi/dns_desec.sh b/dnsapi/dns_desec.sh
index d6b9c355..e5e4809a 100644
--- a/dnsapi/dns_desec.sh
+++ b/dnsapi/dns_desec.sh
@@ -4,7 +4,7 @@ dns_desec_info='deSEC.io
Site: desec.readthedocs.io/en/latest/
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_desec
Options:
- DDNSS_Token API Token
+ DEDYN_TOKEN API Token
Issues: github.com/acmesh-official/acme.sh/issues/2180
Author: Zheng Qian
'
@@ -39,6 +39,7 @@ dns_desec_add() {
_err "invalid domain"
return 1
fi
+ _sub_domain=$(echo "$_sub_domain" | _lower_case)
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
@@ -48,7 +49,7 @@ dns_desec_add() {
_desec_rest GET "$REST_API/$_domain/rrsets/$_sub_domain/TXT/"
if [ "$_code" = "200" ]; then
- oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"\\S*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")"
+ oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"[^ ]*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")"
_debug "existing TXT found"
_debug oldtxtvalues "$oldtxtvalues"
if [ -n "$oldtxtvalues" ]; then
@@ -100,7 +101,7 @@ dns_desec_rm() {
_err "invalid domain"
return 1
fi
-
+ _sub_domain=$(echo "$_sub_domain" | _lower_case)
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
@@ -110,7 +111,7 @@ dns_desec_rm() {
_desec_rest GET "$REST_API/$_domain/rrsets/$_sub_domain/TXT/"
if [ "$_code" = "200" ]; then
- oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"\\S*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")"
+ oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"[^ ]*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")"
_debug "existing TXT found"
_debug oldtxtvalues "$oldtxtvalues"
if [ -n "$oldtxtvalues" ]; then
@@ -150,6 +151,8 @@ _desec_rest() {
if [ "$m" != "GET" ]; then
_secure_debug2 data "$data"
response="$(_post "$data" "$ep" "" "$m")"
+ _info "Sleeping 1s to respect deSEC write rate limit"
+ _sleep 1
else
response="$(_get "$ep")"
fi
diff --git a/dnsapi/dns_dnsexit.sh b/dnsapi/dns_dnsexit.sh
index ec3b07a4..b92482cf 100644
--- a/dnsapi/dns_dnsexit.sh
+++ b/dnsapi/dns_dnsexit.sh
@@ -5,14 +5,11 @@ Site: DNSExit.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_dnsexit
Options:
DNSEXIT_API_KEY API Key
- DNSEXIT_AUTH_USER Username
- DNSEXIT_AUTH_PASS Password
Issues: github.com/acmesh-official/acme.sh/issues/4719
Author: Samuel Jimenez
'
DNSEXIT_API_URL="https://api.dnsexit.com/dns/"
-DNSEXIT_HOSTS_URL="https://update.dnsexit.com/ipupdate/hosts.jsp"
######## Public functions #####################
#Usage: dns_dnsexit_add _acme-challenge.*.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
@@ -28,20 +25,7 @@ dns_dnsexit_add() {
return 1
fi
- _debug 'First detect the root zone'
- if ! _get_root "$fulldomain"; then
- return 1
- fi
- _debug _sub_domain "$_sub_domain"
- _debug _domain "$_domain"
-
- if ! _dnsexit_rest "{\"domain\":\"$_domain\",\"add\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\",\"ttl\":0,\"overwrite\":false}}"; then
- _err "$response"
- return 1
- fi
-
- _debug2 _response "$response"
- return 0
+ _dnsexit_zone_op add ',"ttl":1,"overwrite":false'
}
#Usage: fulldomain txtvalue
@@ -58,54 +42,43 @@ dns_dnsexit_rm() {
return 1
fi
- _debug 'First detect the root zone'
- if ! _get_root "$fulldomain"; then
- _err "$response"
- return 1
- fi
- _debug _sub_domain "$_sub_domain"
- _debug _domain "$_domain"
-
- if ! _dnsexit_rest "{\"domain\":\"$_domain\",\"delete\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\"}}"; then
- _err "$response"
- return 1
- fi
-
- _debug2 _response "$response"
- return 0
+ _dnsexit_zone_op delete ''
}
#################### Private functions below ##################################
-#_acme-challenge.www.domain.com
-#returns
-# _sub_domain=_acme-challenge.www
-# _domain=domain.com
-_get_root() {
- domain=$1
+# The legacy zone-detection endpoint (update.dnsexit.com/ipupdate/hosts.jsp)
+# was shut down by DNSExit and now returns 503, and the JSON API offers no
+# zone-list call. So find the root zone by attempting the actual operation at
+# each domain level: the API answers "code":0 only when the domain matches a
+# zone of the account. https://github.com/acmesh-official/acme.sh/issues/6914
+#Usage: _dnsexit_zone_op
+_dnsexit_zone_op() {
+ _op="$1"
+ _extra="$2"
i=1
while true; do
- _domain=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
- _debug h "$_domain"
+ _domain=$(printf "%s" "$fulldomain" | cut -d . -f "$i"-100)
+ _debug _domain "$_domain"
if [ -z "$_domain" ]; then
+ _err "Could not find the root zone of $fulldomain in your DNSExit account"
return 1
fi
- _debug login "$DNSEXIT_AUTH_USER"
- _debug password "$DNSEXIT_AUTH_PASS"
- _debug domain "$_domain"
+ _sub_domain="$(printf "%s" "$fulldomain" | sed "s/\\.$_domain\$//")"
+ if [ "$_sub_domain" = "$fulldomain" ]; then
+ _sub_domain=""
+ fi
+ _debug _sub_domain "$_sub_domain"
- _dnsexit_http "login=$DNSEXIT_AUTH_USER&password=$DNSEXIT_AUTH_PASS&domain=$_domain"
-
- if _contains "$response" "0=$_domain"; then
- _sub_domain="$(echo "$fulldomain" | sed "s/\\.$_domain\$//")"
- return 0
- else
- _debug "Go to next level of $_domain"
+ if _dnsexit_rest "{\"domain\":\"$_domain\",\"$_op\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\"$_extra}}"; then
+ if _contains "$response" "\"code\":0" || _contains "$response" "\"code\": 0"; then
+ _debug2 _response "$response"
+ return 0
+ fi
+ _debug "Zone $_domain was not accepted, trying the next level" "$response"
fi
i=$(_math "$i" + 1)
done
-
- return 1
}
_dnsexit_rest() {
@@ -136,27 +109,7 @@ _dnsexit_rest() {
return 0
}
-_dnsexit_http() {
- m=GET
- param="$1"
- _debug param "$param"
- _debug get "$DNSEXIT_HOSTS_URL?$param"
-
- response="$(_get "$DNSEXIT_HOSTS_URL?$param")"
-
- _debug response "$response"
-
- if [ "$?" != "0" ]; then
- _err "Error $param"
- return 1
- fi
-
- _debug2 response "$response"
- return 0
-}
-
get_account_info() {
-
DNSEXIT_API_KEY="${DNSEXIT_API_KEY:-$(_readaccountconf_mutable DNSEXIT_API_KEY)}"
if test -z "$DNSEXIT_API_KEY"; then
DNSEXIT_API_KEY=''
@@ -166,23 +119,5 @@ get_account_info() {
_saveaccountconf_mutable DNSEXIT_API_KEY "$DNSEXIT_API_KEY"
- DNSEXIT_AUTH_USER="${DNSEXIT_AUTH_USER:-$(_readaccountconf_mutable DNSEXIT_AUTH_USER)}"
- if test -z "$DNSEXIT_AUTH_USER"; then
- DNSEXIT_AUTH_USER=""
- _err 'DNSEXIT_AUTH_USER was not exported'
- return 1
- fi
-
- _saveaccountconf_mutable DNSEXIT_AUTH_USER "$DNSEXIT_AUTH_USER"
-
- DNSEXIT_AUTH_PASS="${DNSEXIT_AUTH_PASS:-$(_readaccountconf_mutable DNSEXIT_AUTH_PASS)}"
- if test -z "$DNSEXIT_AUTH_PASS"; then
- DNSEXIT_AUTH_PASS=""
- _err 'DNSEXIT_AUTH_PASS was not exported'
- return 1
- fi
-
- _saveaccountconf_mutable DNSEXIT_AUTH_PASS "$DNSEXIT_AUTH_PASS"
-
return 0
}
diff --git a/dnsapi/dns_dnsimple.sh b/dnsapi/dns_dnsimple.sh
index 10a3821d..257549b4 100644
--- a/dnsapi/dns_dnsimple.sh
+++ b/dnsapi/dns_dnsimple.sh
@@ -5,6 +5,7 @@ Site: DNSimple.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_dnsimple
Options:
DNSimple_OAUTH_TOKEN OAuth Token
+ DNSimple_ACCOUNT_ID Account ID. Optional, only needed when the token can access multiple accounts.
Issues: github.com/pho3nixf1re/acme.sh/issues
'
@@ -17,6 +18,7 @@ dns_dnsimple_add() {
fulldomain=$1
txtvalue=$2
+ DNSimple_OAUTH_TOKEN="${DNSimple_OAUTH_TOKEN:-$(_readaccountconf_mutable DNSimple_OAUTH_TOKEN)}"
if [ -z "$DNSimple_OAUTH_TOKEN" ]; then
DNSimple_OAUTH_TOKEN=""
_err "You have not set the dnsimple oauth token yet."
@@ -25,10 +27,10 @@ dns_dnsimple_add() {
fi
# save the oauth token for later
- _saveaccountconf DNSimple_OAUTH_TOKEN "$DNSimple_OAUTH_TOKEN"
+ _saveaccountconf_mutable DNSimple_OAUTH_TOKEN "$DNSimple_OAUTH_TOKEN"
if ! _get_account_id; then
- _err "failed to retrive account id"
+ _err "failed to retrieve account id"
return 1
fi
@@ -56,8 +58,14 @@ dns_dnsimple_add() {
dns_dnsimple_rm() {
fulldomain=$1
+ DNSimple_OAUTH_TOKEN="${DNSimple_OAUTH_TOKEN:-$(_readaccountconf_mutable DNSimple_OAUTH_TOKEN)}"
+ if [ -z "$DNSimple_OAUTH_TOKEN" ]; then
+ _err "You have not set the dnsimple oauth token yet."
+ return 1
+ fi
+
if ! _get_account_id; then
- _err "failed to retrive account id"
+ _err "failed to retrieve account id"
return 1
fi
@@ -122,13 +130,16 @@ _get_root() {
# returns _account_id
_get_account_id() {
- _debug "retrive account id"
- if ! _dnsimple_rest GET "whoami"; then
- return 1
+ DNSimple_ACCOUNT_ID="${DNSimple_ACCOUNT_ID:-$(_readaccountconf_mutable DNSimple_ACCOUNT_ID)}"
+ if [ "$DNSimple_ACCOUNT_ID" ]; then
+ _saveaccountconf_mutable DNSimple_ACCOUNT_ID "$DNSimple_ACCOUNT_ID"
+ _account_id="$DNSimple_ACCOUNT_ID"
+ _debug _account_id "$_account_id"
+ return 0
fi
- if _contains "$response" "\"account\":null"; then
- _err "no account associated with this token"
+ _debug "retrieve account id"
+ if ! _dnsimple_rest GET "whoami"; then
return 1
fi
@@ -137,7 +148,25 @@ _get_account_id() {
return 1
fi
+ if _contains "$response" "\"account\":null"; then
+ # the whoami of a user token (dnsimple_u_*) carries no account,
+ # so list the accounts the token can access instead
+ # https://github.com/acmesh-official/acme.sh/issues/6491
+ if ! _dnsimple_rest GET "accounts"; then
+ return 1
+ fi
+ fi
+
_account_id=$(printf "%s" "$response" | _egrep_o "\"id\":[^,]*,\"email\":" | cut -d: -f2 | cut -d, -f1)
+ if [ -z "$_account_id" ]; then
+ _err "no account associated with this token"
+ return 1
+ fi
+ if [ "$(echo "$_account_id" | wc -l)" -gt 1 ]; then
+ _err "The token has access to multiple accounts, please pick one and set it explicitly:"
+ _err "export DNSimple_ACCOUNT_ID="
+ return 1
+ fi
_debug _account_id "$_account_id"
return 0
diff --git a/dnsapi/dns_dynu.sh b/dnsapi/dns_dynu.sh
index 1d1fc311..3ac5c2f1 100644
--- a/dnsapi/dns_dynu.sh
+++ b/dnsapi/dns_dynu.sh
@@ -23,6 +23,8 @@ dns_dynu_add() {
fulldomain=$1
txtvalue=$2
+ Dynu_ClientId="${Dynu_ClientId:-$(_readaccountconf_mutable Dynu_ClientId)}"
+ Dynu_Secret="${Dynu_Secret:-$(_readaccountconf_mutable Dynu_Secret)}"
if [ -z "$Dynu_ClientId" ] || [ -z "$Dynu_Secret" ]; then
Dynu_ClientId=""
Dynu_Secret=""
@@ -32,8 +34,8 @@ dns_dynu_add() {
fi
#save the client id and secret to the account conf file.
- _saveaccountconf Dynu_ClientId "$Dynu_ClientId"
- _saveaccountconf Dynu_Secret "$Dynu_Secret"
+ _saveaccountconf_mutable Dynu_ClientId "$Dynu_ClientId"
+ _saveaccountconf_mutable Dynu_Secret "$Dynu_Secret"
if [ -z "$Dynu_Token" ]; then
_info "Getting Dynu token."
@@ -69,6 +71,8 @@ dns_dynu_rm() {
fulldomain=$1
txtvalue=$2
+ Dynu_ClientId="${Dynu_ClientId:-$(_readaccountconf_mutable Dynu_ClientId)}"
+ Dynu_Secret="${Dynu_Secret:-$(_readaccountconf_mutable Dynu_Secret)}"
if [ -z "$Dynu_ClientId" ] || [ -z "$Dynu_Secret" ]; then
Dynu_ClientId=""
Dynu_Secret=""
@@ -78,8 +82,8 @@ dns_dynu_rm() {
fi
#save the client id and secret to the account conf file.
- _saveaccountconf Dynu_ClientId "$Dynu_ClientId"
- _saveaccountconf Dynu_Secret "$Dynu_Secret"
+ _saveaccountconf_mutable Dynu_ClientId "$Dynu_ClientId"
+ _saveaccountconf_mutable Dynu_Secret "$Dynu_Secret"
if [ -z "$Dynu_Token" ]; then
_info "Getting Dynu token."
@@ -214,11 +218,11 @@ _dynu_authentication() {
response="$(_get "$Dynu_EndPoint/oauth2/token")"
if [ "$?" != "0" ]; then
- _err "Authentication failed."
+ _err "Authentication failed: no response from $Dynu_EndPoint/oauth2/token"
return 1
fi
if _contains "$response" "Authentication Exception"; then
- _err "Authentication failed."
+ _err "Authentication failed. Server response: $response"
return 1
fi
if _contains "$response" "access_token"; then
diff --git a/dnsapi/dns_edgedns.sh b/dnsapi/dns_edgedns.sh
index e88a1483..9ff1cc06 100755
--- a/dnsapi/dns_edgedns.sh
+++ b/dnsapi/dns_edgedns.sh
@@ -363,17 +363,12 @@ _edgedns_rest() {
_edgedns_eg_timestamp() {
_debug "Generating signature Timestamp"
- _debug3 "Retriving ntp time"
- _timeheaders="$(_get "https://www.ntp.org" "onlyheader")"
- _debug3 "_timeheaders" "$_timeheaders"
- _ntpdate="$(echo "$_timeheaders" | grep -i "Date:" | _head_n 1 | cut -d ':' -f 2- | tr -d "\r\n")"
- _debug3 "_ntpdate" "$_ntpdate"
- _ntpdate="$(echo "${_ntpdate}" | sed -e 's/^[[:space:]]*//')"
- _debug3 "_NTPDATE" "$_ntpdate"
- _ntptime="$(echo "${_ntpdate}" | _head_n 1 | cut -d " " -f 5 | tr -d "\r\n")"
- _debug3 "_ntptime" "$_ntptime"
- _eg_timestamp=$(date -u "+%Y%m%dT")
- _eg_timestamp="$(printf "%s%s+0000" "$_eg_timestamp" "$_ntptime")"
+ #Akamai accepts a clock skew of +/-30s, so use the system clock directly.
+ #The previous code fetched the Date header from www.ntp.org, which is not
+ #a reliable time source (it served a wrong time for hours, issue 3973),
+ #cost an extra https round-trip for every API request, and combined the
+ #remote time of day with the LOCAL date, breaking around UTC midnight.
+ _eg_timestamp="$(date -u "+%Y%m%dT%H:%M:%S+0000")"
_debug "_eg_timestamp" "$_eg_timestamp"
}
diff --git a/dnsapi/dns_eurodns.sh b/dnsapi/dns_eurodns.sh
new file mode 100644
index 00000000..0fac4cb5
--- /dev/null
+++ b/dnsapi/dns_eurodns.sh
@@ -0,0 +1,267 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_eurodns_info='EuroDNS
+Site: eurodns.com
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_eurodns
+Options:
+ EURODNS_APP_ID Application ID
+ EURODNS_API_KEY API Key
+ EURODNS_TTL TTL. Default: "600".
+Issues: github.com/acmesh-official/acme.sh/issues
+Author: Nicolas Santorelli
+'
+
+#
+# EuroDNS DNS API
+#
+# EuroDNS API documentation:
+# https://docapi.eurodns.com
+#
+# Usage:
+# export EURODNS_APP_ID="your-app-id"
+# export EURODNS_API_KEY="your-api-key"
+# acme.sh --issue --dns dns_eurodns -d example.com -d *.example.com
+#
+# The credentials will be saved in ~/.acme.sh/account.conf
+#
+# Optional:
+# export EURODNS_API_URL="https://rest-api.eurodns.com" # Default API URL
+# export EURODNS_TTL=600 # Default TTL (minimum 600 for EuroDNS)
+#
+
+EURODNS_API_DEFAULT="https://rest-api.eurodns.com"
+EURODNS_TTL_DEFAULT=600
+
+######## Public functions #####################
+
+#Usage: dns_eurodns_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_eurodns_add() {
+ fulldomain="$(echo "$1" | _lower_case)"
+ txtvalue=$2
+
+ _info "Using EuroDNS DNS API"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ EURODNS_APP_ID="${EURODNS_APP_ID:-$(_readaccountconf_mutable EURODNS_APP_ID)}"
+ EURODNS_API_KEY="${EURODNS_API_KEY:-$(_readaccountconf_mutable EURODNS_API_KEY)}"
+ EURODNS_API_URL="${EURODNS_API_URL:-$(_readaccountconf_mutable EURODNS_API_URL)}"
+ EURODNS_API_URL="${EURODNS_API_URL:-$EURODNS_API_DEFAULT}"
+ EURODNS_TTL="${EURODNS_TTL:-$(_readaccountconf_mutable EURODNS_TTL)}"
+ EURODNS_TTL="${EURODNS_TTL:-$EURODNS_TTL_DEFAULT}"
+
+ if [ -z "$EURODNS_APP_ID" ] || [ -z "$EURODNS_API_KEY" ]; then
+ EURODNS_APP_ID=""
+ EURODNS_API_KEY=""
+ _err "You didn't specify EuroDNS App ID and API Key."
+ _err "Please export EURODNS_APP_ID and EURODNS_API_KEY and try again."
+ return 1
+ fi
+
+ _saveaccountconf_mutable EURODNS_APP_ID "$EURODNS_APP_ID"
+ _saveaccountconf_mutable EURODNS_API_KEY "$EURODNS_API_KEY"
+ if [ "$EURODNS_API_URL" != "$EURODNS_API_DEFAULT" ]; then
+ _saveaccountconf_mutable EURODNS_API_URL "$EURODNS_API_URL"
+ fi
+ if [ "$EURODNS_TTL" != "$EURODNS_TTL_DEFAULT" ]; then
+ _saveaccountconf_mutable EURODNS_TTL "$EURODNS_TTL"
+ fi
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "Invalid domain"
+ return 1
+ fi
+ _debug _domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+
+ _info "Adding TXT record"
+ if _eurodns_add_txt_record "$_domain" "$_sub_domain" "$txtvalue"; then
+ _info "Added TXT record successfully."
+ return 0
+ else
+ _err "Failed to add TXT record."
+ return 1
+ fi
+}
+
+#Usage: fulldomain txtvalue
+dns_eurodns_rm() {
+ fulldomain="$(echo "$1" | _lower_case)"
+ txtvalue=$2
+
+ _info "Using EuroDNS DNS API"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ EURODNS_APP_ID="${EURODNS_APP_ID:-$(_readaccountconf_mutable EURODNS_APP_ID)}"
+ EURODNS_API_KEY="${EURODNS_API_KEY:-$(_readaccountconf_mutable EURODNS_API_KEY)}"
+ EURODNS_API_URL="${EURODNS_API_URL:-$(_readaccountconf_mutable EURODNS_API_URL)}"
+ EURODNS_API_URL="${EURODNS_API_URL:-$EURODNS_API_DEFAULT}"
+
+ if [ -z "$EURODNS_APP_ID" ] || [ -z "$EURODNS_API_KEY" ]; then
+ EURODNS_APP_ID=""
+ EURODNS_API_KEY=""
+ _err "You didn't specify EuroDNS App ID and API Key."
+ return 1
+ fi
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "Invalid domain"
+ return 1
+ fi
+ _debug _domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+
+ _info "Removing TXT record"
+ if _eurodns_rm_txt_record "$_domain" "$_sub_domain" "$txtvalue"; then
+ _info "Removed TXT record successfully."
+ return 0
+ else
+ _err "Failed to remove TXT record."
+ return 1
+ fi
+}
+
+#################### Private functions below ##################################
+
+# _sub_domain=_acme-challenge.www
+# _domain=domain.com
+_get_root() {
+ domain=$1
+ i=1
+ p=1
+
+ while true; do
+ h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+ _debug h "$h"
+ if [ -z "$h" ]; then
+ return 1
+ fi
+
+ _eurodns_rest GET "dns-zones/$h"
+ if [ "$?" != "0" ]; then
+ if [ "$_code" = "404" ]; then
+ _debug "Zone $h not found, continuing..."
+ else
+ _err "API error looking up zone $h"
+ return 1
+ fi
+ p=$i
+ i=$(_math "$i" + 1)
+ continue
+ fi
+
+ if _contains "$response" '"name"'; then
+ if [ "$i" = "1" ]; then
+ _sub_domain="@"
+ else
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ fi
+ _domain=$h
+ return 0
+ fi
+
+ p=$i
+ i=$(_math "$i" + 1)
+ done
+
+ return 1
+}
+
+_eurodns_add_txt_record() {
+ domain=$1
+ subdomain=$2
+ txtvalue=$3
+
+ data='[{"type":"TXT","host":"'"$subdomain"'","rdata":"'"$txtvalue"'","ttl":'"$EURODNS_TTL"'}]'
+
+ _debug "Adding TXT record via API"
+ if _eurodns_rest POST "dns-zones/$domain/dns-records" "$data"; then
+ if _contains "$response" "$txtvalue"; then
+ return 0
+ fi
+ fi
+ _err "Failed to add TXT record"
+ return 1
+}
+
+_eurodns_rm_txt_record() {
+ domain=$1
+ subdomain=$2
+ txtvalue=$3
+
+ _debug "Getting current zone data for $domain"
+
+ if ! _eurodns_rest GET "dns-zones/$domain"; then
+ _err "Failed to get zone data"
+ return 1
+ fi
+
+ zone_data=$(echo "$response" | _normalizeJson)
+ _debug2 zone_data "$zone_data"
+
+ # Find the record ID matching our TXT record
+ record_id=$(echo "$zone_data" | tr '{' '\n' | grep -F '"TXT"' | grep -F "\"$subdomain\"" | grep -F "\"$txtvalue\"" | _egrep_o '"id" *: *[0-9]+' | cut -d : -f 2 | _head_n 1)
+ _debug record_id "$record_id"
+
+ if [ -z "$record_id" ]; then
+ _info "TXT record not found or already removed"
+ return 0
+ fi
+
+ _debug "Deleting TXT record $record_id"
+ if ! _eurodns_rest DELETE "dns-zones/$domain/dns-records/$record_id"; then
+ _err "Failed to delete TXT record"
+ return 1
+ fi
+
+ return 0
+}
+
+# Usage: _eurodns_rest METHOD ENDPOINT [DATA]
+_eurodns_rest() {
+ method=$1
+ endpoint=$2
+ data="$3"
+
+ export _H1="X-APP-ID: $EURODNS_APP_ID"
+ export _H2="X-API-KEY: $EURODNS_API_KEY"
+ export _H3="Content-Type: application/json"
+
+ url="$EURODNS_API_URL/$endpoint"
+
+ _debug2 url "$url"
+ _debug2 method "$method"
+ _debug2 data "$data"
+
+ : >"$HTTP_HEADER"
+
+ if [ "$method" = "GET" ]; then
+ response="$(_get "$url")"
+ else
+ response="$(_post "$data" "$url" "" "$method")"
+ fi
+
+ _ret="$?"
+ unset _H1 _H2 _H3
+ _debug2 response "$response"
+
+ _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")"
+ _debug2 _code "$_code"
+
+ if [ "$_ret" != "0" ]; then
+ _err "Error calling API: $endpoint"
+ return 1
+ fi
+
+ if [ "$_code" != "200" ] && [ "$_code" != "201" ] && [ "$_code" != "204" ]; then
+ if [ "$_code" != "404" ]; then
+ _err "API error (HTTP $_code): $response"
+ fi
+ return 1
+ fi
+
+ return 0
+}
diff --git a/dnsapi/dns_firestorm.sh b/dnsapi/dns_firestorm.sh
new file mode 100644
index 00000000..808c2b89
--- /dev/null
+++ b/dnsapi/dns_firestorm.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_firestorm_info='Firestorm.ch
+Site: firestorm.ch
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_firestorm
+Options:
+ FST_Key Customer ID
+ FST_Secret API Secret
+ FST_Url API URL. Optional. Default "https://api.firestorm.ch/acme-dns".
+Issues: github.com/acmesh-official/acme.sh/issues/6839
+Author: FireStorm GmbH
+'
+
+FST_Url_DEFAULT="https://api.firestorm.ch/acme-dns"
+
+######## Public functions #####################
+
+# Usage: dns_firestorm_add _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_firestorm_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ FST_Key="${FST_Key:-$(_readaccountconf_mutable FST_Key)}"
+ FST_Secret="${FST_Secret:-$(_readaccountconf_mutable FST_Secret)}"
+ FST_Url="${FST_Url:-$(_readaccountconf_mutable FST_Url)}"
+
+ if [ -z "$FST_Key" ] || [ -z "$FST_Secret" ]; then
+ _err "FST_Key and FST_Secret must be set"
+ _err "Get your API credentials at https://admin.firestorm.ch"
+ return 1
+ fi
+
+ FST_Url="${FST_Url:-$FST_Url_DEFAULT}"
+
+ _saveaccountconf_mutable FST_Key "$FST_Key"
+ _saveaccountconf_mutable FST_Secret "$FST_Secret"
+ if [ "$FST_Url" != "$FST_Url_DEFAULT" ]; then
+ _saveaccountconf_mutable FST_Url "$FST_Url"
+ else
+ _clearaccountconf_mutable FST_Url
+ fi
+
+ subdomain=$(printf "%s" "$fulldomain" | sed 's/^_acme-challenge\.//')
+
+ _info "Adding TXT record for $fulldomain"
+ _debug "Subdomain" "$subdomain"
+ _debug "TXT value" "$txtvalue"
+
+ body="{\"subdomain\":\"$(_json_safe "$subdomain")\",\"txt\":\"$(_json_safe "$txtvalue")\"}"
+
+ response="$(_firestorm_api "update" "$body")"
+
+ if _contains "$response" "$txtvalue"; then
+ _info "TXT record added successfully"
+ return 0
+ fi
+
+ _err "Failed to add TXT record: $response"
+ return 1
+}
+
+# Usage: dns_firestorm_rm _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_firestorm_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ FST_Key="${FST_Key:-$(_readaccountconf_mutable FST_Key)}"
+ FST_Secret="${FST_Secret:-$(_readaccountconf_mutable FST_Secret)}"
+ FST_Url="${FST_Url:-$(_readaccountconf_mutable FST_Url)}"
+ FST_Url="${FST_Url:-$FST_Url_DEFAULT}"
+
+ if [ -z "$FST_Key" ] || [ -z "$FST_Secret" ]; then
+ _err "FST_Key and FST_Secret must be set"
+ return 1
+ fi
+
+ subdomain=$(printf "%s" "$fulldomain" | sed 's/^_acme-challenge\.//')
+
+ _info "Removing TXT record for $fulldomain"
+
+ body="{\"subdomain\":\"$(_json_safe "$subdomain")\",\"txt\":\"$(_json_safe "$txtvalue")\"}"
+
+ response="$(_firestorm_api "remove" "$body")"
+
+ if _contains "$response" "removed"; then
+ _info "TXT record removed"
+ return 0
+ fi
+
+ _err "Failed to remove TXT record: $response"
+ return 1
+}
+
+#################### Private functions below ##################################
+
+# Escape special characters for safe JSON string interpolation
+_json_safe() {
+ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
+}
+
+_firestorm_api() {
+ action=$1
+ data=$2
+
+ export _H1="X-Api-User: $FST_Key"
+ export _H2="X-Api-Key: $FST_Secret"
+ export _H3="Content-Type: application/json"
+
+ _post "$data" "$FST_Url/$action" "" "POST"
+}
diff --git a/dnsapi/dns_freedns.sh b/dnsapi/dns_freedns.sh
index 13d9f68b..8ea86c24 100755
--- a/dnsapi/dns_freedns.sh
+++ b/dnsapi/dns_freedns.sh
@@ -305,7 +305,7 @@ _freedns_domain_id() {
fi
domain_id="$(echo "$htmlpage" | tr -d " \t\r\n\v\f" | sed 's/
/@
/g' | tr '@' '\n' |
- grep "
$search_domain
\|
$search_domain(.*)
" |
+ grep -E "
$search_domain
|
$search_domain\(.*\)
" |
sed -n 's/.*\(edit\.php?edit_domain_id=[0-9a-zA-Z]*\).*/\1/p' |
cut -d = -f 2)"
# The above beauty extracts domain ID from the html page...
diff --git a/dnsapi/dns_freemyip.sh b/dnsapi/dns_freemyip.sh
index d598a657..18d8e7f9 100644
--- a/dnsapi/dns_freemyip.sh
+++ b/dnsapi/dns_freemyip.sh
@@ -6,7 +6,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_freemyip
Options:
FREEMYIP_Token API Token
Issues: github.com/acmesh-official/acme.sh/issues/6247
-Author: Recolic Keghart , @Giova96
+Author: Recolic Keghart , @Giova96, ExtremeFiretop
'
FREEMYIP_DNS_API="https://freemyip.com/update?"
@@ -68,22 +68,30 @@ dns_freemyip_rm() {
return $?
}
-################ Private functions below ################
+################ Private functions below ################
_get_root() {
_fmi_d="$1"
- echo "$_fmi_d" | rev | cut -d '.' -f 1-3 | rev
+ echo "$_fmi_d" | sed 's/.*\.\([^.]*\.[^.]*\.[^.]*\)$/\1/'
}
# There is random failure while calling freemyip API too fast. This function automatically retry until success.
_freemyip_get_until_ok() {
_fmi_url="$1"
- for i in $(seq 1 8); do
- _debug "HTTP GET freemyip.com API '$_fmi_url', retry $i/8..."
- _get "$_fmi_url" | tee /dev/fd/2 | grep OK && return 0
+ _fmi_i=1
+ while [ "$_fmi_i" -le 8 ]; do
+ _debug "HTTP GET freemyip.com API '$_fmi_url', retry $_fmi_i/8..."
+ _fmi_response="$(_get "$_fmi_url")"
+ printf '%s\n' "$_fmi_response" >&2
+
+ if _contains "$_fmi_response" "OK"; then
+ return 0
+ fi
+
_sleep 1 # DO NOT send the request too fast
+ _fmi_i=$((_fmi_i + 1))
done
- _err "Failed to request freemyip API: $_fmi_url . Server does not say 'OK'"
+ _err "Failed to request freemyip API. Server does not say 'OK'"
return 1
}
@@ -93,13 +101,16 @@ _is_root_domain_published() {
_webroot="$(_get_root "$_fmi_d")"
_info "Verifying '""$_fmi_d""' freemyip webroot (""$_webroot"") is not published yet"
- for i in $(seq 1 3); do
- _debug "'$_webroot' ns lookup, retry $i/3..."
+ _fmi_i=1
+ while [ "$_fmi_i" -le 3 ]; do
+ _debug "'$_webroot' ns lookup, retry $_fmi_i/3..."
+
if [ "$(_ns_lookup "$_fmi_d" TXT)" ]; then
_debug "'$_webroot' already has a TXT record published!"
return 0
fi
_sleep 10 # Give it some time to propagate the TXT record
+ _fmi_i=$((_fmi_i + 1))
done
return 1
}
diff --git a/dnsapi/dns_gd.sh b/dnsapi/dns_gd.sh
index ee66ee19..c92bdfa2 100755
--- a/dnsapi/dns_gd.sh
+++ b/dnsapi/dns_gd.sh
@@ -69,7 +69,12 @@ dns_gd_add() {
return 1
fi
- if ! _contains "$response" "$txtvalue"; then
+ if _contains "$response" "UNKNOWN_DOMAIN"; then
+ # GoDaddy sometimes returns UNKNOWN_DOMAIN when reading a record back even
+ # though the PUT above succeeded; skip the local readback check and let
+ # acme.sh's own DNS propagation check verify the record was published.
+ _info "GoDaddy API won't allow reading the record back; skipping local verification."
+ elif ! _contains "$response" "$txtvalue"; then
_err "TXT record '${txtvalue}' for '${fulldomain}', value wasn't set!"
return 1
fi
@@ -145,8 +150,8 @@ dns_gd_rm() {
# _domain=domain.com
_get_root() {
domain=$1
- i=2
- p=1
+ i=1
+ p=0
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
if [ -z "$h" ]; then
@@ -154,17 +159,41 @@ _get_root() {
return 1
fi
- if ! _gd_rest GET "domains/$h"; then
- return 1
+ # The record name is whatever precedes the candidate zone. Do not assume
+ # _acme-challenge here: with DNS alias mode it can be any name, and the
+ # record may even sit at the zone apex (name "@").
+ if [ "$p" = "0" ]; then
+ _probe_sub="@"
+ else
+ _probe_sub=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
fi
- if _contains "$response" '"code":"NOT_FOUND"'; then
- _debug "$h not found"
- else
- _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ # Probe with the records endpoint instead of "GET domains/$h": since
+ # 2024-05 GoDaddy rejects the domain details call for accounts with
+ # fewer than 10 domains, while record-level calls keep working.
+ # https://github.com/acmesh-official/acme.sh/issues/4487
+ if ! _gd_rest GET "domains/$h/records/TXT/$_probe_sub"; then
+ return 1
+ fi
+ if _startswith "$response" '\['; then
+ _sub_domain="$_probe_sub"
_domain="$h"
return 0
fi
+
+ # Some accounts get UNKNOWN_DOMAIN when reading records of a valid zone
+ # even though writes succeed (see issue #6517); fall back to the domain
+ # details call for them.
+ if ! _gd_rest GET "domains/$h"; then
+ return 1
+ fi
+ if _contains "$response" '"domainId"'; then
+ _sub_domain="$_probe_sub"
+ _domain="$h"
+ return 0
+ fi
+
+ _debug "$h not found"
p="$i"
i=$(_math "$i" + 1)
done
diff --git a/dnsapi/dns_glesys.sh b/dnsapi/dns_glesys.sh
new file mode 100644
index 00000000..008abd12
--- /dev/null
+++ b/dnsapi/dns_glesys.sh
@@ -0,0 +1,263 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_glesys_info='Glesys
+Site: Glesys.se
+Docs: https://github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_glesys
+Options:
+ GLESYS_API_KEY Generated API key.
+ GLESYS_PROJECT_ID Project ID for the API key (e.g. cl12345).
+ GLESYS_API API endpoint. Default "https://api.glesys.com/domain".
+ GLESYS_TTL TXT record TTL. Default 120.
+Issues: https://github.com/acmesh-official/acme.sh/issues/7057
+Author: Toni Karppi
+'
+
+GLESYS_API_DEFAULT="https://api.glesys.com/domain"
+GLESYS_TTL_DEFAULT="120"
+
+######## Public functions #####################################################
+
+# Usage:
+# dns_glesys_add _acme-challenge.www.example.com "txt-value"
+dns_glesys_add() {
+ fulldomain="$1"
+ txtvalue="$2"
+
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ _glesys_init || return 1
+
+ if ! _glesys_get_root "$fulldomain"; then
+ _err "Could not find root zone for $fulldomain"
+ return 1
+ fi
+
+ _debug _domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+
+ host_value="${_sub_domain:-@}"
+ _debug _host_value "$host_value"
+
+ data="{\"domainname\":\"$_domain\",\"host\":\"$host_value\",\"type\":\"TXT\",\"data\":\"$txtvalue\",\"ttl\":\"$GLESYS_TTL\"}"
+
+ _debug2 data "$data"
+
+ if ! _glesys_rest POST "/addrecord" "$data"; then
+ _err "Failed to send HTTP request to add TXT record"
+ return 1
+ fi
+
+ response_code=$(
+ printf "%s" "$response" |
+ tr -d '\r\n\t ' |
+ _egrep_o '"code":"?[0-9]+' |
+ _egrep_o '[0-9]+$'
+ )
+
+ _debug response_code "$response_code"
+
+ if [ "$response_code" != "200" ]; then
+ _err "GleSYS API responded with an unexpected status when attempting to add TXT record"
+ _debug2 "API response" "$response"
+ return 1
+ fi
+
+ _info "TXT record added"
+
+ return 0
+}
+
+# Usage:
+# dns_glesys_rm _acme-challenge.www.example.com "txt-value"
+dns_glesys_rm() {
+ fulldomain="$1"
+ txtvalue="$2"
+
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ _glesys_init || return 1
+
+ if ! _glesys_get_root "$fulldomain"; then
+ _err "Could not find root zone for $fulldomain"
+ return 1
+ fi
+
+ if ! _glesys_find_record_id "$txtvalue"; then
+ _info "TXT record not present, skip removal"
+ return 0
+ fi
+
+ _debug _record_id "$_record_id"
+
+ if ! _glesys_rest POST "/deleterecord" "{\"recordid\":$_record_id}"; then
+ _err "Failed to send HTTP request to remove TXT record"
+ return 1
+ fi
+
+ response_code=$(
+ printf "%s" "$response" |
+ tr -d '\r\n\t ' |
+ _egrep_o '"code":"?[0-9]+' |
+ _egrep_o '[0-9]+$'
+ )
+
+ _debug response_code "$response_code"
+
+ if [ "$response_code" != "200" ]; then
+ _err "GleSYS API responded with unexpected status when attempting to remove TXT record"
+ _debug2 "API response" "$response"
+ return 1
+ fi
+
+ _info "TXT record removed"
+
+ return 0
+}
+
+######## Private functions ####################################################
+
+_glesys_find_record_id() {
+ txtvalue="$1"
+
+ _debug txtvalue "$txtvalue"
+
+ if [ -z "$txtvalue" ]; then
+ return 1
+ fi
+
+ _record_id=""
+
+ _debug "Looking for TXT record with value" "$txtvalue"
+
+ if ! _glesys_rest GET "/listrecords?domainname=$_domain"; then
+ _err "Failed to list DNS records"
+ return 1
+ fi
+
+ records="$(
+ printf "%s" "$response" |
+ tr -d '\r\n\t ' |
+ sed 's/},{/}\
+{/g'
+ )"
+
+ _debug2 records "$records"
+
+ expected_data="\"data\":\"$txtvalue\""
+
+ _record_id="$(
+ printf "%s\n" "$records" |
+ while IFS= read -r record; do
+ printf "%s" "$record" | grep -q '"type":"TXT"' || continue
+ printf "%s" "$record" | grep -Fq "$expected_data" || continue
+
+ printf "%s" "$record" |
+ grep -E -o '"recordid":"?[0-9]+' |
+ grep -E -o '[0-9]+$'
+
+ break
+ done
+ )"
+
+ _debug _record_id "$_record_id"
+
+ if [ -z "$_record_id" ]; then
+ return 1
+ fi
+
+ return 0
+}
+
+# Finds:
+# _domain example.com
+# _sub_domain _acme-challenge.www
+_glesys_get_root() {
+ domain="$1"
+ i=1
+
+ while true; do
+ h="$(printf "%s" "$domain" | cut -d . -f "$i"-100)"
+
+ if [ -z "$h" ]; then
+ return 1
+ fi
+
+ if _glesys_rest GET "/listrecords?domainname=$h"; then
+ response_code=$(
+ printf "%s" "$response" |
+ tr -d '\r\n\t ' |
+ _egrep_o '"code":"?[0-9]+' |
+ _egrep_o '[0-9]+$'
+ )
+
+ _debug response_code "$response_code"
+
+ if [ "$response_code" = "200" ]; then
+ cut_len="$((${#domain} - ${#h} - 1))"
+ _domain="$h"
+ _sub_domain="$(printf "%s" "$domain" | cut -c "1-$cut_len")"
+ return 0
+ fi
+ fi
+
+ i="$((i + 1))"
+ done
+}
+
+_glesys_init() {
+ [ -z "$GLESYS_API" ] && GLESYS_API="$GLESYS_API_DEFAULT"
+ [ -z "$GLESYS_TTL" ] && GLESYS_TTL="$GLESYS_TTL_DEFAULT"
+
+ _debug GLESYS_API "$GLESYS_API"
+ _debug GLESYS_TTL "$GLESYS_TTL"
+
+ GLESYS_API_KEY="${GLESYS_API_KEY:-$(_readaccountconf_mutable GLESYS_API_KEY)}"
+ GLESYS_PROJECT_ID="${GLESYS_PROJECT_ID:-$(_readaccountconf_mutable GLESYS_PROJECT_ID)}"
+
+ if [ -z "$GLESYS_API_KEY" ] || [ -z "$GLESYS_PROJECT_ID" ]; then
+ _err "GLESYS_API_KEY and GLESYS_PROJECT_ID must be set for this provider"
+ return 1
+ fi
+
+ _secure_debug GLESYS_API_KEY "$GLESYS_API_KEY"
+ _secure_debug GLESYS_PROJECT_ID "$GLESYS_PROJECT_ID"
+
+ _glesys_basic_auth="$(printf "%s:%s" "$GLESYS_PROJECT_ID" "$GLESYS_API_KEY" | _base64)"
+ _secure_debug2 _glesys_basic_auth "$_glesys_basic_auth"
+
+ _saveaccountconf_mutable GLESYS_API_KEY "$GLESYS_API_KEY"
+ _saveaccountconf_mutable GLESYS_PROJECT_ID "$GLESYS_PROJECT_ID"
+
+ return 0
+}
+
+_glesys_rest() {
+ method="$1"
+ path="$2"
+ data="$3"
+
+ export _H1="Authorization: Basic $_glesys_basic_auth"
+ export _H2="Content-Type: application/json"
+ export _H3="Accept: application/json"
+
+ url="$GLESYS_API$path"
+ _debug "$method $url"
+
+ if [ "$method" = "GET" ]; then
+ response="$(_get "$url")"
+ else
+ response="$(_post "$data" "$url" "" "$method")"
+ fi
+
+ ret="$?"
+ _debug2 response "$response"
+ _debug ret "$ret"
+
+ if [ "$ret" != "0" ]; then
+ return 1
+ fi
+
+ return 0
+}
diff --git a/dnsapi/dns_hestiacp.sh b/dnsapi/dns_hestiacp.sh
new file mode 100644
index 00000000..13ee6caf
--- /dev/null
+++ b/dnsapi/dns_hestiacp.sh
@@ -0,0 +1,198 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_hestiacp_info='HestiaCP Server API
+Site: hestiacp.com
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_hestiacp
+Options:
+ HESTIA_HOST Panel URL. E.g. "https://panel.example.com:8083"
+ HESTIA_ACCESS API access key
+ HESTIA_SECRET API secret key
+ HESTIA_USER Username owning the DNS zones. Default "admin". Optional.
+Issues: github.com/acmesh-official/acme.sh/issues/6251
+Author: Radu Malica
+'
+
+######## Public functions #####################
+
+# Usage: dns_hestiacp_add fulldomain txtvalue
+dns_hestiacp_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ if ! _hestia_init; then
+ return 1
+ fi
+
+ _debug "Detecting the root zone for $fulldomain"
+ if ! _hestia_get_root "$fulldomain"; then
+ _err "Cannot find a DNS zone for $fulldomain under user $HESTIA_USER"
+ return 1
+ fi
+ _debug _hestia_domain "$_hestia_domain"
+ _debug _hestia_sub "$_hestia_sub"
+
+ # _hestia_get_root left the zone record listing in _hestia_response
+ if _hestia_find_records "$_hestia_sub" "TXT" | grep -F -- "$txtvalue" >/dev/null; then
+ _info "The TXT record already exists, skipping"
+ return 0
+ fi
+
+ _info "Adding TXT record for $fulldomain"
+ if ! _hestia_rest "v-add-dns-record" "$HESTIA_USER" "$_hestia_domain" "$_hestia_sub" "TXT" "$txtvalue" "" "" "yes" "600"; then
+ _err "Error adding TXT record: $_hestia_response"
+ return 1
+ fi
+ _info "TXT record added successfully"
+ return 0
+}
+
+# Usage: dns_hestiacp_rm fulldomain txtvalue
+dns_hestiacp_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ if ! _hestia_init; then
+ return 1
+ fi
+
+ _debug "Detecting the root zone for $fulldomain"
+ if ! _hestia_get_root "$fulldomain"; then
+ _err "Cannot find a DNS zone for $fulldomain under user $HESTIA_USER"
+ return 1
+ fi
+ _debug _hestia_domain "$_hestia_domain"
+ _debug _hestia_sub "$_hestia_sub"
+
+ _hestia_removed=0
+ _hestia_failed=0
+ while IFS='|' read -r _hestia_id _hestia_value || [ -n "$_hestia_id" ]; do
+ if [ -z "$_hestia_id" ]; then
+ continue
+ fi
+ if ! _contains "$_hestia_value" "$txtvalue"; then
+ continue
+ fi
+ _info "Deleting TXT record $_hestia_id"
+ if ! _hestia_rest "v-delete-dns-record" "$HESTIA_USER" "$_hestia_domain" "$_hestia_id" "yes"; then
+ _err "Error deleting TXT record $_hestia_id: $_hestia_response"
+ _hestia_failed=$(_math "$_hestia_failed" + 1)
+ continue
+ fi
+ _hestia_removed=$(_math "$_hestia_removed" + 1)
+ done <
+'
+
+HOSTINGER_Api="https://developers.hostinger.com/api/dns/v1/zones"
+
+######## Public functions #####################
+
+#Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_hostinger_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ HOSTINGER_Token="${HOSTINGER_Token:-$(_readaccountconf_mutable HOSTINGER_Token)}"
+
+ if [ -z "$HOSTINGER_Token" ]; then
+ HOSTINGER_Token=""
+ _err "You didn't specify a Hostinger API Key yet."
+ _err "Please read the documentation for the Hostinger API authentication at https://developers.hostinger.com/#description/authentication"
+ return 1
+ fi
+ _saveaccountconf_mutable HOSTINGER_Token "$HOSTINGER_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"
+
+ _debug "Getting existing records"
+ _hostinger_rest GET "${_domain}"
+
+ if [ -z "$response" ]; then
+ _err "Error"
+ 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 record"
+ if _hostinger_rest PUT "$_domain" "{\"zone\":[{\"name\": \"$_sub_domain\",\"records\": [{\"content\":\"$txtvalue\"}],\"type\":\"TXT\",\"ttl\":\"120\"}],\"overwrite\":false}"; then
+ if _contains "$response" "Request accepted"; then
+ _info "Added, OK"
+ return 0
+ elif _contains "$response" "DNS resource record is not valid or conflicts with another resource record" ||
+ _contains "$response" 'DNS:4008'; then
+ _info "Already exists, OK"
+ return 0
+ else
+ _err "Add txt record error."
+ return 1
+ fi
+ fi
+ _err "Add txt record error."
+ return 1
+
+}
+
+#fulldomain txtvalue
+dns_hostinger_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ HOSTINGER_Token="${HOSTINGER_Token:-$(_readaccountconf_mutable HOSTINGER_Token)}"
+
+ if [ -z "$HOSTINGER_Token" ]; then
+ HOSTINGER_Token=""
+ _err "You didn't specify a Hostinger API Key yet."
+ _err "Please read the documentation for the Hostinger API authentication at https://developers.hostinger.com/#description/authentication"
+ return 1
+ fi
+ _saveaccountconf_mutable HOSTINGER_Token "$HOSTINGER_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"
+
+ _debug "Getting existing records"
+ _hostinger_rest GET "${_domain}"
+
+ if [ -z "$response" ]; then
+ _err "Error"
+ return 1
+ fi
+
+ if _contains "$response" "\"name\":\"$_sub_domain\""; then
+ # Match the record, and make certain it is a TXT record for the domain not another type. Then remove our target record from the list
+ remaining_records=$(echo "$response" | _normalizeJson | _egrep_o '{"name":"'"$_sub_domain"'","records":\[[^]]+\],"ttl":[0-9]+,"type":"TXT"\}' | _egrep_o "\[.*\]" | sed -E 's#\{"content":"\\"'"$txtvalue"'\\"","is_disabled":false\},?##g')
+ if [ "$remaining_records" != "[]" ]; then
+ remaining_json=$(echo "$remaining_records" | _egrep_o '"content":"\\"[^}]+\\""' | sed -E 's/^(.*)$/{\1},/g' | tr -d '\n' | sed 's/,$//')
+ # We need to set the remaining records back to Hostinger, as we can't partially delete
+ _info "Removing $txtvalue from $_sub_domain by setting records to ${remaining_json}"
+ if _hostinger_rest PUT "$_domain" "{\"zone\":[{\"name\": \"$_sub_domain\",\"records\": [${remaining_json}],\"type\":\"TXT\",\"ttl\":\"120\"}],\"overwrite\":true}"; then
+ if _contains "$response" "Request accepted"; then
+ _info "Updated remaining records, OK"
+ return 0
+ elif _contains "$response" "DNS resource record is not valid or conflicts with another resource record" ||
+ _contains "$response" 'DNS:4008'; then
+ _info "Already exists, OK"
+ return 0
+ else
+ _err "Add txt record error."
+ return 1
+ fi
+ fi
+ # Otherwise delete the TXT record that matches the subdomain
+ else
+ if ! _hostinger_rest DELETE "$_domain" "{\"filters\":[{\"name\":\"$_sub_domain\",\"type\":\"TXT\"}]}"; then
+ _err "Delete record error."
+ return 1
+ fi
+ fi
+ echo "$response" | grep "Request accepted" >/dev/null
+ else
+ _info "Don't need to remove."
+ fi
+
+}
+
+#################### Private functions below ##################################
+#_acme-challenge.www.domain.com
+#returns
+# _sub_domain=_acme-challenge.www
+# _domain=domain.com
+_get_root() {
+ domain=$1
+ i=1
+ p=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
+
+ _hostinger_rest GET "$h"
+ if _contains "$response" "records"; then
+ if [ "$response" = "[]" ]; then
+ _debug "Valid subdomains are not the root"
+ else
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ _domain=$h
+ return 0
+ fi
+ fi
+
+ p=$i
+ i=$(_math "$i" + 1)
+ done
+ return 1
+}
+
+_hostinger_rest() {
+ m=$1
+ ep="$2"
+ data="$3"
+ _debug "$ep"
+
+ token_trimmed=$(echo "$HOSTINGER_Token" | tr -d '"')
+
+ export _H1="Content-Type: application/json"
+ export _H2="Authorization: Bearer $token_trimmed"
+
+ if [ "$m" != "GET" ]; then
+ _debug data "$data"
+ response="$(_post "$data" "$HOSTINGER_Api/$ep" "" "$m")"
+ else
+ response="$(_get "$HOSTINGER_Api/$ep")"
+ fi
+
+ if [ "$?" != "0" ]; then
+ _err "error $ep"
+ return 1
+ fi
+ _debug2 response "$response"
+ return 0
+}
diff --git a/dnsapi/dns_hostup.sh b/dnsapi/dns_hostup.sh
index b3211069..a3d9174a 100644
--- a/dnsapi/dns_hostup.sh
+++ b/dnsapi/dns_hostup.sh
@@ -6,13 +6,13 @@ 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_API_BASE Optional. Override API base URL (default: https://cloud.hostup.se/api/v2).
HOSTUP_TTL Optional. TTL for TXT records (default: 60 seconds).
- HOSTUP_ZONE_ID Optional. Force a specific zone ID (skip auto-detection).
+ HOSTUP_ZONE_ID Optional. Force a specific v2 zone ID (zone_...) and skip auto-detection.
Author: HostUp (https://cloud.hostup.se/contact/en)
'
-HOSTUP_API_BASE_DEFAULT="https://cloud.hostup.se/api"
+HOSTUP_API_BASE_DEFAULT="https://cloud.hostup.se/api/v2"
HOSTUP_DEFAULT_TTL=60
# Public: add TXT record
@@ -20,6 +20,7 @@ HOSTUP_DEFAULT_TTL=60
dns_hostup_add() {
fulldomain="$1"
txtvalue="$2"
+ hostup_add_txtvalue="$2"
_info "Using HostUp DNS API"
@@ -34,31 +35,34 @@ dns_hostup_add() {
record_name="$(_hostup_record_name "$fulldomain" "$HOSTUP_ZONE_DOMAIN")"
record_name="$(_hostup_sanitize_name "$record_name")"
- record_value="$(_hostup_json_escape "$txtvalue")"
+ hostup_add_record_value="$(_hostup_json_escape "$hostup_add_txtvalue")"
- ttl="${HOSTUP_TTL:-$HOSTUP_DEFAULT_TTL}"
+ raw_ttl="${HOSTUP_TTL:-$HOSTUP_DEFAULT_TTL}"
+ ttl="$(_hostup_normalize_ttl "$raw_ttl")"
+ if [ -z "$ttl" ]; then
+ _err "HOSTUP_TTL must be a whole number between 60 and 86400 seconds."
+ return 1
+ fi
+ if [ -n "$HOSTUP_TTL" ]; then
+ HOSTUP_TTL="$ttl"
+ _saveaccountconf_mutable HOSTUP_TTL "$HOSTUP_TTL"
+ fi
_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
+ record_name_fqdn="$(_hostup_fqdn "$fulldomain")"
+ if _hostup_find_record "$HOSTUP_ZONE_ID" "$record_name_fqdn" "$hostup_add_txtvalue"; then
+ _info "TXT record already exists for $fulldomain"
+ return 0
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
+ request_body="{\"name\":\"$record_name\",\"type\":\"TXT\",\"value\":\"$hostup_add_record_value\",\"ttl\":$ttl}"
- 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"
+ if ! _hostup_rest "POST" "/dns-zones/$HOSTUP_ZONE_ID/records" "$request_body"; then
+ return 1
fi
_info "Added TXT record for $fulldomain"
@@ -85,20 +89,9 @@ dns_hostup_rm() {
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"
+ _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" "$record_value"
return 0
fi
@@ -109,7 +102,7 @@ dns_hostup_rm() {
fi
_info "Deleted TXT record $HOSTUP_RECORD_ID"
- _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain"
+ _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" "$record_value"
HOSTUP_ZONE_ID=""
return 0
}
@@ -127,21 +120,18 @@ _hostup_init() {
if [ -z "$HOSTUP_API_BASE" ]; then
HOSTUP_API_BASE="$HOSTUP_API_BASE_DEFAULT"
fi
+ HOSTUP_API_BASE="$(_hostup_normalize_api_base "$HOSTUP_API_BASE")"
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."
+ _err "Please export your HostUp API key with read:dns, write:dns, and read:domains 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
@@ -149,11 +139,80 @@ _hostup_init() {
return 0
}
+_hostup_normalize_api_base() {
+ api_base="${1%/}"
+
+ case "$api_base" in
+ */api/v2)
+ printf "%s" "$api_base"
+ ;;
+ */api)
+ printf "%s/v2" "$api_base"
+ ;;
+ *)
+ printf "%s" "$api_base"
+ ;;
+ esac
+}
+
+_hostup_normalize_ttl() {
+ ttl_value="$1"
+
+ case "$ttl_value" in
+ "" | *[!0-9]*)
+ return 1
+ ;;
+ esac
+
+ while [ "${ttl_value#0}" != "$ttl_value" ]; do
+ ttl_value="${ttl_value#0}"
+ done
+ [ -z "$ttl_value" ] && ttl_value=0
+
+ case "$ttl_value" in
+ ??????*)
+ return 1
+ ;;
+ esac
+
+ if [ "$ttl_value" -lt 60 ] || [ "$ttl_value" -gt 86400 ]; then
+ return 1
+ fi
+
+ printf "%s" "$ttl_value"
+}
+
+_hostup_domain_in_zone() {
+ host="$(printf "%s" "${1%.}" | _lower_case)"
+ zone="$(printf "%s" "${2%.}" | _lower_case)"
+
+ if [ -z "$host" ] || [ -z "$zone" ]; then
+ return 1
+ fi
+
+ if [ "$host" = "$zone" ]; then
+ return 0
+ fi
+
+ case "$host" in
+ *."$zone")
+ return 0
+ ;;
+ esac
+
+ return 1
+}
+
_hostup_detect_zone() {
fulldomain="$1"
if [ -n "$HOSTUP_ZONE_ID" ] && [ -n "$HOSTUP_ZONE_DOMAIN" ]; then
- return 0
+ if _hostup_domain_in_zone "$fulldomain" "$HOSTUP_ZONE_DOMAIN"; then
+ return 0
+ fi
+ _debug "hostup_cached_zone_mismatch" "$HOSTUP_ZONE_DOMAIN"
+ HOSTUP_ZONE_ID=""
+ HOSTUP_ZONE_DOMAIN=""
fi
HOSTUP_ZONE_DOMAIN=""
@@ -162,16 +221,16 @@ _hostup_detect_zone() {
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
+ if _hostup_domain_in_zone "$fulldomain" "$HOSTUP_ZONE_DOMAIN"; then
+ return 0
+ fi
+ _debug "hostup_forced_zone_mismatch" "$HOSTUP_ZONE_DOMAIN"
fi
HOSTUP_ZONE_ID=""
+ HOSTUP_ZONE_DOMAIN=""
fi
- if ! _hostup_load_zones; then
- return 1
- fi
-
- _domain_candidate="$(printf "%s" "$fulldomain" | _lower_case)"
+ _domain_candidate="$(printf "%s" "${fulldomain%.}" | _lower_case)"
_debug "hostup_initial_candidate" "$_domain_candidate"
while [ -n "$_domain_candidate" ]; do
@@ -240,11 +299,11 @@ _hostup_fqdn() {
_hostup_fetch_zone_details() {
zone_id="$1"
- if ! _hostup_rest "GET" "/dns/zones/$zone_id/records" ""; then
+ 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 '"')"
+ zonedomain="$(_hostup_json_extract "name" "$_hostup_response")"
if [ -n "$zonedomain" ]; then
HOSTUP_ZONE_DOMAIN="$zonedomain"
return 0
@@ -254,7 +313,7 @@ _hostup_fetch_zone_details() {
}
_hostup_load_zones() {
- if ! _hostup_rest "GET" "/dns/zones" ""; then
+ if ! _hostup_rest "GET" "/dns-zones?limit=1000" ""; then
return 1
fi
@@ -263,9 +322,9 @@ _hostup_load_zones() {
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")"
+ *'"id"'*'"name"'*)
+ zone_id="$(_hostup_json_extract "id" "$line")"
+ zone_domain="$(_hostup_json_extract "name" "$line")"
if [ -n "$zone_id" ] && [ -n "$zone_domain" ]; then
HOSTUP_ZONES_CACHE="${HOSTUP_ZONES_CACHE}${zone_domain}|${zone_id}
"
@@ -290,9 +349,30 @@ _hostup_lookup_zone() {
_lookup_zone_id=""
_lookup_zone_domain=""
+ encoded_domain="$(printf "%s" "$lookup_domain" | _url_encode)"
+ if _hostup_rest "GET" "/dns-zones?name=$encoded_domain&limit=1" ""; then
+ zone_id="$(_hostup_json_extract "id" "$_hostup_response")"
+ zone_domain="$(_hostup_json_extract "name" "$_hostup_response")"
+ if [ -n "$zone_id" ] && [ -n "$zone_domain" ]; then
+ zone_domain_lower="$(printf "%s" "$zone_domain" | _lower_case)"
+ if [ "$zone_domain_lower" = "$lookup_domain" ]; then
+ _lookup_zone_domain="$zone_domain"
+ _lookup_zone_id="$zone_id"
+ HOSTUP_ZONE_DOMAIN="$zone_domain"
+ HOSTUP_ZONE_ID="$zone_id"
+ return 0
+ fi
+ fi
+ fi
+
+ if [ -z "$HOSTUP_ZONES_CACHE" ] && ! _hostup_load_zones; then
+ return 1
+ fi
+
while IFS='|' read -r domain zone_id; do
[ -z "$domain" ] && continue
- if [ "$domain" = "$lookup_domain" ]; then
+ domain_lower="$(printf "%s" "$domain" | _lower_case)"
+ if [ "$domain_lower" = "$lookup_domain" ]; then
_lookup_zone_domain="$domain"
_lookup_zone_id="$zone_id"
HOSTUP_ZONE_DOMAIN="$domain"
@@ -307,50 +387,50 @@ EOF
}
_hostup_find_record() {
- zone_id="$1"
- fqdn="$2"
- txtvalue="$3"
+ _hostup_find_zone_id="$1"
+ _hostup_find_fqdn="$2"
+ _hostup_find_txtvalue="$3"
- if ! _hostup_rest "GET" "/dns/zones/$zone_id/records" ""; then
+ _hostup_find_encoded_name="$(printf "%s" "$_hostup_find_fqdn" | _url_encode)"
+ if ! _hostup_rest "GET" "/dns-zones/$_hostup_find_zone_id/records?type=TXT&name=$_hostup_find_encoded_name" ""; then
return 1
fi
HOSTUP_RECORD_ID=""
- records="$(printf "%s" "$_hostup_response" | tr '{' '\n')"
+ _hostup_find_records="$(printf "%s" "$_hostup_response" | tr '{' '\n')"
- while IFS= read -r line; do
+ while IFS= read -r _hostup_find_line; do
# Normalize line to make TXT value matching reliable
- line_clean="$(printf "%s" "$line" | tr -d '\r\n')"
- line_value_clean="$(printf "%s" "$line_clean" | sed 's/\\"//g')"
+ _hostup_find_line_clean="$(printf "%s" "$_hostup_find_line" | tr -d '\r\n')"
+ _hostup_find_line_value_clean="$(printf "%s" "$_hostup_find_line_clean" | sed 's/\\"//g')"
- case "$line_clean" in
- *'"type":"TXT"'*'"name"'*'"value"'*)
- name_value="$(_hostup_json_extract "name" "$line_clean")"
- record_value="$(_hostup_json_extract "value" "$line_value_clean")"
+ _hostup_find_record_type="$(_hostup_json_extract "type" "$_hostup_find_line_clean")"
+ [ "$_hostup_find_record_type" != "TXT" ] && continue
- _debug "hostup_record_raw" "$record_value"
- if [ "${record_value#\"}" != "$record_value" ] && [ "${record_value%\"}" != "$record_value" ]; then
- record_value="${record_value#\"}"
- record_value="${record_value%\"}"
- fi
- if [ "${record_value#\'}" != "$record_value" ] && [ "${record_value%\'}" != "$record_value" ]; then
- record_value="${record_value#\'}"
- record_value="${record_value%\'}"
- fi
- record_value="$(printf "%s" "$record_value" | tr -d '\r\n')"
- _debug "hostup_record_value" "$record_value"
+ _hostup_find_name_value="$(_hostup_json_extract "name" "$_hostup_find_line_clean")"
+ _hostup_find_record_value="$(_hostup_json_extract "value" "$_hostup_find_line_value_clean")"
- if [ "$name_value" = "$fqdn" ] && [ "$record_value" = "$txtvalue" ]; then
- record_id="$(_hostup_json_extract "id" "$line_clean")"
- if [ -n "$record_id" ]; then
- HOSTUP_RECORD_ID="$record_id"
- return 0
- fi
+ _debug "hostup_record_raw" "$_hostup_find_record_value"
+ if [ "${_hostup_find_record_value#\"}" != "$_hostup_find_record_value" ] && [ "${_hostup_find_record_value%\"}" != "$_hostup_find_record_value" ]; then
+ _hostup_find_record_value="${_hostup_find_record_value#\"}"
+ _hostup_find_record_value="${_hostup_find_record_value%\"}"
+ fi
+ if [ "${_hostup_find_record_value#\'}" != "$_hostup_find_record_value" ] && [ "${_hostup_find_record_value%\'}" != "$_hostup_find_record_value" ]; then
+ _hostup_find_record_value="${_hostup_find_record_value#\'}"
+ _hostup_find_record_value="${_hostup_find_record_value%\'}"
+ fi
+ _hostup_find_record_value="$(printf "%s" "$_hostup_find_record_value" | tr -d '\r\n')"
+ _debug "hostup_record_value" "$_hostup_find_record_value"
+
+ if [ "$_hostup_find_name_value" = "$_hostup_find_fqdn" ] && [ "$_hostup_find_record_value" = "$_hostup_find_txtvalue" ]; then
+ _hostup_find_record_id="$(_hostup_json_extract "id" "$_hostup_find_line_clean")"
+ if [ -n "$_hostup_find_record_id" ]; then
+ HOSTUP_RECORD_ID="$_hostup_find_record_id"
+ return 0
fi
- ;;
- esac
+ fi
done <berlight.de" (the 'g' from
+ # "" plus '.' matching '>'). See issue #5129.
+ _hregex=$(printf "%s" "$h" | sed 's/\./\\./g')
+ if _contains "$response" "$_hregex"; then
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
_domain="$h"
return 0
fi
+ # IDN fallback: INWX returns Unicode zone names; when $h is ACE/punycode,
+ # encode each zone name via _idn() and compare -- no python dependency.
+ if _contains "$h" "xn--"; then
+ _zone_unicode=$(printf "%s" "$response" | _egrep_o '[^<]*' |
+ sed 's/<[^>]*>//g' | while IFS= read -r _z; do
+ if [ "$(_idn "$_z")" = "$h" ]; then
+ printf "%s" "$_z"
+ break
+ fi
+ done)
+ if [ -n "$_zone_unicode" ]; then
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ _domain="$_zone_unicode"
+ return 0
+ fi
+ fi
p=$i
i=$(_math "$i" + 1)
done
diff --git a/dnsapi/dns_ionos.sh b/dnsapi/dns_ionos.sh
index 9a464253..00662e82 100755
--- a/dnsapi/dns_ionos.sh
+++ b/dnsapi/dns_ionos.sh
@@ -16,7 +16,7 @@ IONOS_TXT_TTL=60 # minimum accepted by API
IONOS_TXT_PRIO=10
dns_ionos_add() {
- fulldomain=$1
+ fulldomain="$(echo "$1" | _lower_case)"
txtvalue=$2
if ! _ionos_init; then
@@ -34,7 +34,7 @@ dns_ionos_add() {
}
dns_ionos_rm() {
- fulldomain=$1
+ fulldomain="$(echo "$1" | _lower_case)"
txtvalue=$2
if ! _ionos_init; then
@@ -146,7 +146,7 @@ _ionos_rest() {
if [ "$method" != "GET" ]; then
export _H2="Accept: application/json"
- export _H3="Content-Type: application/json"
+ export _H3=
_response="$(_post "$data" "$IONOS_API$route" "" "$method" "application/json")"
else
diff --git a/dnsapi/dns_ipprojects.sh b/dnsapi/dns_ipprojects.sh
new file mode 100644
index 00000000..dadd05f0
--- /dev/null
+++ b/dnsapi/dns_ipprojects.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_ipprojects_info='IP-Projects DNS
+Site: ip-projects.de/
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_ipprojects
+Options:
+ IPP_Apikey API Key
+Issues: github.com/acmesh-official/acme.sh/issues/6958
+Author: Markus Ebner
+'
+
+IPP_Apikey="${IPP_Apikey:-$(_readaccountconf_mutable IPP_Apikey)}"
+IPP_API="https://api.ip-projects.de/v1/dns/acme"
+
+######## Public functions ########
+
+dns_ipprojects_add() {
+ fulldomain="$1"
+ txtvalue="$2"
+
+ _info "Using IP-Projects DNS API to add record"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ if ! _IPP_load_credentials; then
+ return 1
+ fi
+
+ _IPP_api_request "add" "$fulldomain" "$txtvalue"
+}
+
+dns_ipprojects_rm() {
+ fulldomain="$1"
+ txtvalue="$2"
+
+ _info "Using IP-Projects DNS API to remove record"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ if ! _IPP_load_credentials; then
+ return 1
+ fi
+
+ _IPP_api_request "remove" "$fulldomain" "$txtvalue"
+}
+
+######## Private helpers ########
+
+_IPP_load_credentials() {
+ IPP_Apikey="${IPP_Apikey:-$(_readaccountconf_mutable IPP_Apikey)}"
+
+ if [ -z "$IPP_Apikey" ]; then
+ _err "You must export IPP_Apikey"
+ _err "e.g.: export IPP_Apikey=\"your_api_key\""
+ return 1
+ fi
+
+ _saveaccountconf_mutable IPP_Apikey "$IPP_Apikey"
+ return 0
+}
+
+_IPP_api_request() {
+ action="$1"
+ domain="$2"
+ value="$3"
+
+ url="$IPP_API/$action"
+
+ data="{\"domain\":\"$domain\",\"key\":\"$domain\",\"value\":\"$value\"}"
+ _debug url "$url"
+ _debug data "$data"
+ export _H1="X-API-Key: $IPP_Apikey"
+
+ response="$(_post "$data" "$url" "" "POST" "application/json")"
+ ret="$?"
+ _ipprojects_last_http_code=$(grep "^HTTP" "${HTTP_HEADER}" | _tail_n 1 | cut -d " " -f 2 | tr -d '\r\n')
+
+ _debug response "$response"
+
+ if [ "$ret" != "0" ]; then
+ _err "HTTP request failed"
+ return 1
+ fi
+
+ if [ "$_ipprojects_last_http_code" != "200" ]; then
+ _err "API returned an error [code: ${_ipprojects_last_http_code}]"
+ return 1
+ fi
+
+ return 0
+}
diff --git a/dnsapi/dns_ispconfig.sh b/dnsapi/dns_ispconfig.sh
index edc789e1..bd6bfb28 100755
--- a/dnsapi/dns_ispconfig.sh
+++ b/dnsapi/dns_ispconfig.sh
@@ -136,7 +136,7 @@ _ISPC_getZoneInfo() {
curResult="$(_post "${curData}" "${ISPC_Api}?client_get_id")"
_debug "Calling _ISPC_ClientGetID: '${curData}' '${ISPC_Api}?client_get_id'"
_debug "Result of _ISPC_ClientGetID: '$curResult'"
- client_id=$(echo "${curResult}" | _egrep_o "response.*" | cut -d ':' -f 2 | cut -d '"' -f 2 | tr -d '{}')
+ client_id=$(echo "${curResult}" | _egrep_o "response.*" | cut -d ':' -f 2 | cut -d '"' -f 2 | cut -d '[' -f 1 | tr -d '{}')
_debug "Client ID: '${client_id}'"
case "${client_id}" in
'' | *[!0-9]*)
diff --git a/dnsapi/dns_joker.sh b/dnsapi/dns_joker.sh
index 401471be..0ad80327 100644
--- a/dnsapi/dns_joker.sh
+++ b/dnsapi/dns_joker.sh
@@ -35,9 +35,28 @@ dns_joker_add() {
return 1
fi
+ # Joker's /nic/replace overwrites all TXT records at the label on every call,
+ # and the API is not readable, so accumulate the values locally (keyed by the
+ # full record name) and re-send the whole set each time. This is required so a
+ # wildcard cert (base + *.domain both validating under the same
+ # _acme-challenge label) does not overwrite its own first challenge value.
+ _joker_conf_key=$(printf "%s" "JOKER_TXT_${fulldomain}" | tr '.-' '_')
+ _joker_values=$(_readdomainconf "$_joker_conf_key")
+ if [ -z "$_joker_values" ]; then
+ _joker_values="$txtvalue"
+ elif ! _contains " $_joker_values " " $txtvalue "; then
+ _joker_values="$_joker_values $txtvalue"
+ fi
+
+ _joker_value_params=""
+ for _joker_v in $_joker_values; do
+ _joker_value_params="$_joker_value_params&value=$_joker_v"
+ done
+
_info "Adding TXT record"
- if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT&value=$txtvalue"; then
+ if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT$_joker_value_params"; then
if _startswith "$response" "OK"; then
+ _savedomainconf "$_joker_conf_key" "$_joker_values"
_info "Added, OK"
return 0
fi
@@ -59,10 +78,36 @@ dns_joker_rm() {
return 1
fi
+ # Remove only this value from the accumulated set and replace the label with
+ # whatever remains (an empty value clears the label's TXT records entirely).
+ _joker_conf_key=$(printf "%s" "JOKER_TXT_${fulldomain}" | tr '.-' '_')
+ _joker_values=$(_readdomainconf "$_joker_conf_key")
+ _joker_remaining=""
+ for _joker_v in $_joker_values; do
+ if [ "$_joker_v" != "$txtvalue" ]; then
+ _joker_remaining="$_joker_remaining $_joker_v"
+ fi
+ done
+ _joker_remaining=$(printf "%s" "$_joker_remaining" | sed 's/^ *//')
+
+ _joker_value_params=""
+ for _joker_v in $_joker_remaining; do
+ _joker_value_params="$_joker_value_params&value=$_joker_v"
+ done
+ if [ -z "$_joker_value_params" ]; then
+ _joker_value_params="&value="
+ fi
+
_info "Removing TXT record"
- # TXT record is removed by setting its value to empty.
- if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT&value="; then
+ # TXT record is removed by replacing the label with the remaining values
+ # (or an empty value, which clears all TXT records at the label).
+ if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT$_joker_value_params"; then
if _startswith "$response" "OK"; then
+ if [ -z "$_joker_remaining" ]; then
+ _cleardomainconf "$_joker_conf_key"
+ else
+ _savedomainconf "$_joker_conf_key" "$_joker_remaining"
+ fi
_info "Removed, OK"
return 0
fi
diff --git a/dnsapi/dns_knot.sh b/dnsapi/dns_knot.sh
index 5636804a..2b6d8ef4 100644
--- a/dnsapi/dns_knot.sh
+++ b/dnsapi/dns_knot.sh
@@ -5,7 +5,8 @@ Site: www.knot-dns.cz/docs/2.5/html/man_knsupdate.html
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_knot
Options:
KNOT_SERVER Server hostname. Default: "localhost".
- KNOT_KEY File path to TSIG key
+ KNOT_KEY TSIG key data, not a file path. knsupdate "key" statement format: "[alg:]name secret". E.g. "hmac-sha256:acme_key BASE64SECRET="
+ KNOT_ZONE Zone name. Optional, set it when the challenge record lives in a delegated subdomain zone. Default: the parent domain of the challenge record.
'
# See also dns_nsupdate.sh
@@ -21,6 +22,9 @@ dns_knot_add() {
# save the dns server and key to the account.conf file.
_saveaccountconf KNOT_SERVER "${KNOT_SERVER}"
_saveaccountconf KNOT_KEY "${KNOT_KEY}"
+ if [ -n "${KNOT_ZONE}" ]; then
+ _saveaccountconf KNOT_ZONE "${KNOT_ZONE}"
+ fi
if ! _get_root "$fulldomain"; then
_err "Domain does not exist."
@@ -84,6 +88,13 @@ EOF
# _domain=domain.com
_get_root() {
domain=$1
+ # a delegated subdomain zone cannot be derived from the record name;
+ # let the user name the zone explicitly (issue 2881)
+ if [ -n "${KNOT_ZONE}" ]; then
+ _domain="${KNOT_ZONE%.}"
+ _debug "Using KNOT_ZONE zone" "${_domain}"
+ return 0
+ fi
i="$(echo "$fulldomain" | tr '.' ' ' | wc -w)"
i=$(_math "$i" - 1)
diff --git a/dnsapi/dns_laodc.sh b/dnsapi/dns_laodc.sh
new file mode 100644
index 00000000..9f2103b3
--- /dev/null
+++ b/dnsapi/dns_laodc.sh
@@ -0,0 +1,197 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_laodc_info='LaoDC DNS API Server
+Site: laodc.com
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_laodc
+Options:
+ LaoDC_Key API Key
+Issues: github.com/acmesh-official/acme.sh/issues/6973
+Author: @laodc
+'
+
+# Usage:
+# export LaoDC_Key="your-api-key"
+# acme.sh --issue --dns dns_laodc -d example.la -d *.example.la --dnssleep 120
+#
+# The credentials will be saved in ~/.acme.sh/account.conf
+
+LAODC_VER="0.1.2"
+LAODC_API_ENDPOINT="https://dns.laodc.com/v1"
+
+######## Public functions #####################
+
+# Usage: dns_laodc_add _acme-challenge.example.la ZPXvna6tBhq7XQMH7_t2WC2sg0F-BdmtmmpUJiK6Ho
+dns_laodc_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ _info "Using LaoDC DNS API"
+
+ _laodc_validate_key || return 1
+
+ _debug "Checking root zone exists for [$fulldomain]"
+ if ! _get_root "$fulldomain"; then
+ _err "Invalid domain"
+ return 1
+ fi
+
+ domain_hash=$(echo "$response" | _egrep_o "\"hash\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \")
+ _debug _domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain_hash "$domain_hash"
+
+ _info "Adding acme record"
+ if _laodc_api "POST" "$domain_hash" "$_sub_domain" "$txtvalue"; then
+ if [ "$_code" = "201" ]; then
+ _info "Added, OK"
+ return 0
+ else
+ _err "Add TXT record error, invalid code. Code: $_code"
+ return 1
+ fi
+ fi
+
+ _err "Add TXT record error."
+ return 1
+}
+
+dns_laodc_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ _laodc_validate_key || return 1
+
+ _debug "Checking root zone exists for [$fulldomain]"
+ if ! _get_root "$fulldomain"; then
+ _err "Invalid domain"
+ return 1
+ fi
+
+ domain_hash=$(echo "$response" | _egrep_o "\"hash\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \")
+ _debug _root_domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain_hash "$domain_hash"
+
+ _info "Deleting acme record"
+ if _laodc_api "DELETE" "$domain_hash" "$_sub_domain" "$txtvalue"; then
+ if [ "$_code" = "204" ]; then
+ _info "Deleted, OK"
+ return 0
+ else
+ _err "Delete TXT record error, invalid code. Code: $_code"
+ return 1
+ fi
+ fi
+
+ _err "Delete TXT record error."
+ return 1
+}
+
+#################### Private functions below ##################################
+# _acme-challenge.www.domain.com
+# returns
+# _domain=domain.com
+# _sub_domain=www
+_get_root() {
+ fqdn=$1
+ p=1
+ i=1
+
+ while true; do
+ h=$(printf "%s" "$fqdn" | cut -d . -f "$i"-100)
+ if [ -z "$h" ]; then
+ return 1 # not valid domain
+ fi
+
+ # Check API if domain exists
+ if _laodc_api "GET" "$h"; then
+ if [ "$_code" = "200" ]; then
+ _domain="$h"
+
+ # DNS alias mode - @ is alias for fqdn
+ _sub_domain=$(printf "%s" "$fqdn" | cut -d . -f 1-"$p")
+ if [ "$i" = "1" ]; then
+ _sub_domain="@"
+ fi
+
+ return 0
+ fi
+ fi
+
+ p="$i"
+ i=$(_math "$i" + 1)
+ done
+
+ return 1
+}
+
+_laodc_validate_key() {
+ LaoDC_Key="${LaoDC_Key:-$(_readaccountconf_mutable LaoDC_Key)}"
+
+ if [ -z "$LaoDC_Key" ]; then
+ LaoDC_Key=""
+ _err "You didn't specify a LaoDC API Key yet."
+ _err "Please export LaoDC_Key and try again."
+ return 1
+ fi
+
+ # Save the api key to the account conf file.
+ _saveaccountconf_mutable LaoDC_Key "$LaoDC_Key"
+}
+
+_laodc_api() {
+ method=$1
+ domain=$2
+ subdomain=$3
+ value=$4
+
+ export _H1="Content-Type: application/json"
+ export _H2="User-Agent: acme.sh/$VER laodc-dns-acme-sh/$LAODC_VER"
+ export _H3="Authorization: Bearer $LaoDC_Key"
+
+ case $method in
+ GET)
+ if [ -n "$subdomain" ]; then
+ response="$(_get "$LAODC_API_ENDPOINT/$domain/$subdomain?type=TXT")"
+ else
+ response="$(_get "$LAODC_API_ENDPOINT/$domain")"
+ fi
+ ;;
+ POST)
+ # Sanitize value input
+ value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
+ data="{ \"type\": \"TXT\", \"value\": \"$value\", \"ttl\": \"60\" }"
+ response="$(_post "$data" "$LAODC_API_ENDPOINT/$domain/$subdomain" "" "POST" "application/json")"
+ ;;
+ DELETE)
+ # Sanitize value input
+ value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
+ data="{ \"type\": \"TXT\", \"value\": \"$value\" }"
+ response="$(_post "$data" "$LAODC_API_ENDPOINT/$domain/$subdomain" "" "DELETE" "application/json")"
+ ;;
+ esac
+
+ _ret=$?
+
+ # Unset immediately after request to prevent leaks
+ export _H1=
+ export _H2=
+ export _H3=
+
+ if [ "$_ret" != "0" ]; then
+ _err "Error $domain"
+ return 1
+ fi
+
+ responseHeaders="$(cat "$HTTP_HEADER")"
+
+ if echo "$responseHeaders" | grep -i "Content-Type: *application/json" >/dev/null 2>&1; then
+ response="$(echo "$response" | _json_decode | _normalizeJson)"
+ fi
+
+ _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")"
+
+ _debug "http response code $_code"
+ _debug response "$response"
+ return 0
+}
diff --git a/dnsapi/dns_level27.sh b/dnsapi/dns_level27.sh
new file mode 100644
index 00000000..3fbaf810
--- /dev/null
+++ b/dnsapi/dns_level27.sh
@@ -0,0 +1,197 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_level27_info='Level27
+Site: Level27.be
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_level27
+Options:
+ LEVEL27_API_KEY API key. Get one from the Level27 control panel (https://app.level27.eu/account/profile/security).
+OptionsAlt:
+ LEVEL27_API API base URL. Optional. Default "https://api.level27.eu/v1".
+Issues: github.com/acmesh-official/acme.sh/issues
+Author: Jeroen Moors
+'
+
+LEVEL27_API_DEFAULT="https://api.level27.eu/v1"
+
+######## Public functions #####################
+
+# Usage: dns_level27_add _acme-challenge.www.example.com "TXT-value"
+dns_level27_add() {
+ fulldomain="$(_idn "$1")"
+ txtvalue="$2"
+
+ _info "Using Level27 to add a TXT record for $fulldomain"
+
+ if ! _level27_init; then
+ return 1
+ fi
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "Could not determine the root zone for $fulldomain at Level27."
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ _level27_data="{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\"}"
+ if ! _level27_rest POST "domains/$_domain_id/records" "$_level27_data"; then
+ _err "Could not add the TXT record."
+ return 1
+ fi
+
+ if _contains "$response" "\"id\":"; then
+ _info "TXT record added."
+ return 0
+ fi
+
+ _err "Unexpected response while adding the TXT record."
+ return 1
+}
+
+# Usage: dns_level27_rm _acme-challenge.www.example.com "TXT-value"
+dns_level27_rm() {
+ fulldomain="$(_idn "$1")"
+ txtvalue="$2"
+
+ _info "Using Level27 to remove the TXT record for $fulldomain"
+
+ if ! _level27_init; then
+ return 1
+ fi
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "Could not determine the root zone for $fulldomain at Level27."
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ if ! _level27_rest GET "domains/$_domain_id/records?type=TXT"; then
+ _err "Could not list the existing TXT records."
+ return 1
+ fi
+
+ _record_id="$(_level27_find_record_id "$response" "$txtvalue")"
+ if [ -z "$_record_id" ]; then
+ _info "No matching TXT record found; nothing to remove."
+ return 0
+ fi
+ _debug _record_id "$_record_id"
+
+ if ! _level27_rest DELETE "domains/$_domain_id/records/$_record_id"; then
+ _err "Could not remove the TXT record."
+ return 1
+ fi
+
+ _info "TXT record removed."
+ return 0
+}
+
+#################### Private functions below ##################################
+
+# Reads and validates the API credentials and endpoint, and stores them for renewals.
+_level27_init() {
+ LEVEL27_API_KEY="${LEVEL27_API_KEY:-$(_readaccountconf_mutable LEVEL27_API_KEY)}"
+ if [ -z "$LEVEL27_API_KEY" ]; then
+ LEVEL27_API_KEY=""
+ _err "You must export the variable LEVEL27_API_KEY before using the Level27 DNS API."
+ _err "Get an API key from the Level27 control panel (https://app.level27.eu/account/profile/security)."
+ return 1
+ fi
+ LEVEL27_API_KEY="$(echo "$LEVEL27_API_KEY" | tr -d '"')"
+ _saveaccountconf_mutable LEVEL27_API_KEY "$LEVEL27_API_KEY"
+
+ LEVEL27_API="${LEVEL27_API:-$(_readaccountconf_mutable LEVEL27_API)}"
+ if [ -z "$LEVEL27_API" ]; then
+ LEVEL27_API="$LEVEL27_API_DEFAULT"
+ fi
+ _saveaccountconf_mutable LEVEL27_API "$LEVEL27_API"
+
+ # Remove a trailing slash so endpoints can be appended consistently.
+ LEVEL27_API="$(echo "$LEVEL27_API" | sed 's#/$##')"
+ return 0
+}
+
+# Usage: _get_root _acme-challenge.www.example.com
+# Splits the full domain into the registered zone and the subdomain part.
+# Sets: _domain, _domain_id, _sub_domain
+_get_root() {
+ domain=$1
+ i=1
+ p=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 ! _level27_rest GET "domains?filter=$h"; then
+ return 1
+ fi
+
+ _level27_zones="$(echo "$response" | _normalizeJson)"
+ if _contains "$_level27_zones" "\"fullname\":\"$h\""; then
+ _domain_line="$(echo "$_level27_zones" | sed 's/},{/}\n{/g' | grep "\"fullname\":\"$h\"" | _head_n 1)"
+ _domain_id="$(echo "$_domain_line" | _egrep_o '"id":[0-9]*' | _head_n 1 | cut -d : -f 2)"
+ if [ "$_domain_id" ]; then
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ _domain=$h
+ return 0
+ fi
+ return 1
+ fi
+ p=$i
+ i=$(_math "$i" + 1)
+ done
+ return 1
+}
+
+# Usage: _level27_find_record_id "" ""
+# Prints the id of the TXT record whose content matches the value, or nothing.
+_level27_find_record_id() {
+ _records="$(echo "$1" | _normalizeJson | sed 's/},{/}\n{/g')"
+ _wanted="$2"
+ _record_line="$(echo "$_records" | grep "\"content\":\"$_wanted\"" | _head_n 1)"
+ if [ -z "$_record_line" ]; then
+ # Some APIs store TXT content wrapped in quotes.
+ _record_line="$(echo "$_records" | grep "\"content\":\"\\\\\"$_wanted\\\\\"\"" | _head_n 1)"
+ fi
+ if [ -z "$_record_line" ]; then
+ return 0
+ fi
+ echo "$_record_line" | _egrep_o '"id":[0-9]*' | _head_n 1 | cut -d : -f 2
+}
+
+# Usage: _level27_rest [data]
+# Performs an authenticated API call and stores the body in $response.
+_level27_rest() {
+ m="$1"
+ ep="$2"
+ data="$3"
+ _debug "$ep"
+
+ export _H1="Authorization: $LEVEL27_API_KEY"
+ export _H2="Content-Type: application/json"
+ export _H3="Accept: application/json"
+
+ if [ "$m" != "GET" ]; then
+ _debug2 data "$data"
+ response="$(_post "$data" "$LEVEL27_API/$ep" "" "$m")"
+ else
+ response="$(_get "$LEVEL27_API/$ep")"
+ fi
+
+ if [ "$?" != "0" ]; then
+ _err "Error querying the Level27 API endpoint: $ep"
+ return 1
+ fi
+ _debug2 response "$response"
+ return 0
+}
diff --git a/dnsapi/dns_linode.sh b/dnsapi/dns_linode.sh
deleted file mode 100755
index d74d1fc8..00000000
--- a/dnsapi/dns_linode.sh
+++ /dev/null
@@ -1,189 +0,0 @@
-#!/usr/bin/env sh
-# shellcheck disable=SC2034
-dns_linode_info='Linode.com (Old)
- Deprecated. Use dns_linode_v4
-Site: Linode.com
-Options:
- LINODE_API_KEY API Key
-Author: Philipp Grosswiler
-'
-
-LINODE_API_URL="https://api.linode.com/?api_key=$LINODE_API_KEY&api_action="
-
-######## Public functions #####################
-
-#Usage: dns_linode_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
-dns_linode_add() {
- fulldomain="${1}"
- txtvalue="${2}"
-
- if ! _Linode_API; then
- return 1
- fi
-
- _info "Using Linode"
- _debug "Calling: dns_linode_add() '${fulldomain}' '${txtvalue}'"
-
- _debug "First detect the root zone"
- if ! _get_root "$fulldomain"; then
- _err "Domain does not exist."
- return 1
- fi
- _debug _domain_id "$_domain_id"
- _debug _sub_domain "$_sub_domain"
- _debug _domain "$_domain"
-
- _parameters="&DomainID=$_domain_id&Type=TXT&Name=$_sub_domain&Target=$txtvalue"
-
- if _rest GET "domain.resource.create" "$_parameters" && [ -n "$response" ]; then
- _resource_id=$(printf "%s\n" "$response" | _egrep_o "\"ResourceID\":\s*[0-9]+" | cut -d : -f 2 | tr -d " " | _head_n 1)
- _debug _resource_id "$_resource_id"
-
- if [ -z "$_resource_id" ]; then
- _err "Error adding the domain resource."
- return 1
- fi
-
- _info "Domain resource successfully added."
- return 0
- fi
-
- return 1
-}
-
-#Usage: dns_linode_rm _acme-challenge.www.domain.com
-dns_linode_rm() {
- fulldomain="${1}"
-
- if ! _Linode_API; then
- return 1
- fi
-
- _info "Using Linode"
- _debug "Calling: dns_linode_rm() '${fulldomain}'"
-
- _debug "First detect the root zone"
- if ! _get_root "$fulldomain"; then
- _err "Domain does not exist."
- return 1
- fi
- _debug _domain_id "$_domain_id"
- _debug _sub_domain "$_sub_domain"
- _debug _domain "$_domain"
-
- _parameters="&DomainID=$_domain_id"
-
- if _rest GET "domain.resource.list" "$_parameters" && [ -n "$response" ]; then
- response="$(echo "$response" | tr -d "\n" | tr '{' "|" | sed 's/|/&{/g' | tr "|" "\n")"
-
- resource="$(echo "$response" | _egrep_o "{.*\"NAME\":\s*\"$_sub_domain\".*}")"
- if [ "$resource" ]; then
- _resource_id=$(printf "%s\n" "$resource" | _egrep_o "\"RESOURCEID\":\s*[0-9]+" | _head_n 1 | cut -d : -f 2 | tr -d \ )
- if [ "$_resource_id" ]; then
- _debug _resource_id "$_resource_id"
-
- _parameters="&DomainID=$_domain_id&ResourceID=$_resource_id"
-
- if _rest GET "domain.resource.delete" "$_parameters" && [ -n "$response" ]; then
- _resource_id=$(printf "%s\n" "$response" | _egrep_o "\"ResourceID\":\s*[0-9]+" | cut -d : -f 2 | tr -d " " | _head_n 1)
- _debug _resource_id "$_resource_id"
-
- if [ -z "$_resource_id" ]; then
- _err "Error deleting the domain resource."
- return 1
- fi
-
- _info "Domain resource successfully deleted."
- return 0
- fi
- fi
-
- return 1
- fi
-
- return 0
- fi
-
- return 1
-}
-
-#################### Private functions below ##################################
-
-_Linode_API() {
- if [ -z "$LINODE_API_KEY" ]; then
- LINODE_API_KEY=""
-
- _err "You didn't specify the Linode API key yet."
- _err "Please create your key and try again."
-
- return 1
- fi
-
- _saveaccountconf LINODE_API_KEY "$LINODE_API_KEY"
-}
-
-#################### Private functions below ##################################
-#_acme-challenge.www.domain.com
-#returns
-# _sub_domain=_acme-challenge.www
-# _domain=domain.com
-# _domain_id=12345
-_get_root() {
- domain=$1
- i=2
- p=1
-
- if _rest GET "domain.list"; then
- response="$(echo "$response" | tr -d "\n" | tr '{' "|" | sed 's/|/&{/g' | tr "|" "\n")"
- while true; do
- h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
- _debug h "$h"
- if [ -z "$h" ]; then
- #not valid
- return 1
- fi
-
- hostedzone="$(echo "$response" | _egrep_o "{.*\"DOMAIN\":\s*\"$h\".*}")"
- if [ "$hostedzone" ]; then
- _domain_id=$(printf "%s\n" "$hostedzone" | _egrep_o "\"DOMAINID\":\s*[0-9]+" | _head_n 1 | cut -d : -f 2 | tr -d \ )
- if [ "$_domain_id" ]; then
- _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
- _domain=$h
- return 0
- fi
- return 1
- fi
- p=$i
- i=$(_math "$i" + 1)
- done
- fi
- return 1
-}
-
-#method method action data
-_rest() {
- mtd="$1"
- ep="$2"
- data="$3"
-
- _debug mtd "$mtd"
- _debug ep "$ep"
-
- export _H1="Accept: application/json"
- export _H2="Content-Type: application/json"
-
- if [ "$mtd" != "GET" ]; then
- # both POST and DELETE.
- _debug data "$data"
- response="$(_post "$data" "$LINODE_API_URL$ep" "" "$mtd")"
- else
- response="$(_get "$LINODE_API_URL$ep$data")"
- fi
-
- if [ "$?" != "0" ]; then
- _err "error $ep"
- return 1
- fi
- _debug2 response "$response"
- return 0
-}
diff --git a/dnsapi/dns_me.sh b/dnsapi/dns_me.sh
index 163fe8db..0966c5f1 100644
--- a/dnsapi/dns_me.sh
+++ b/dnsapi/dns_me.sh
@@ -140,7 +140,7 @@ _me_rest() {
data="$3"
_debug "$ep"
- cdate=$(LANG=C date -u +"%a, %d %b %Y %T %Z")
+ cdate=$(LC_ALL=C date -u +"%a, %d %b %Y %T %Z")
hmac=$(printf "%s" "$cdate" | _hmac sha1 "$(printf "%s" "$ME_Secret" | _hex_dump | tr -d " ")" hex)
export _H1="x-dnsme-apiKey: $ME_Key"
diff --git a/dnsapi/dns_muumuu.sh b/dnsapi/dns_muumuu.sh
new file mode 100755
index 00000000..8ef0b8c8
--- /dev/null
+++ b/dnsapi/dns_muumuu.sh
@@ -0,0 +1,167 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_muumuu_info='muumuu-domain.com
+Site: muumuu-domain.com
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_muumuu
+Options:
+ MUUMUU_PAT Personal Access Token (scopes: domains:read, dns:read, dns:write)
+Issues: github.com/acmesh-official/acme.sh/issues/7011
+'
+
+MUUMUU_API="https://muumuu-domain.com/api/v2"
+
+######## Public functions #####################
+
+dns_muumuu_add() {
+ fulldomain="$(echo "$1" | _lower_case)"
+ txtvalue="$2"
+
+ _info "Using muumuu-domain.com DNS API"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ MUUMUU_PAT="${MUUMUU_PAT:-$(_readaccountconf_mutable MUUMUU_PAT)}"
+ if [ -z "$MUUMUU_PAT" ]; then
+ _err "MUUMUU_PAT is not set."
+ _err "Please create a Personal Access Token at https://muumuu-domain.com"
+ _err "with scopes: domains:read, dns:read, dns:write"
+ return 1
+ fi
+ _saveaccountconf_mutable MUUMUU_PAT "$MUUMUU_PAT"
+
+ if ! _muumuu_get_root "$fulldomain"; then
+ _err "Unable to find the root domain for $fulldomain"
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ _info "Adding TXT record for ${fulldomain}"
+ body="{\"fqdn\":\"${fulldomain}.\",\"type\":\"TXT\",\"value\":\"${txtvalue}\",\"ttl\":3600}"
+ if _muumuu_rest POST "/me/domains/${_domain_id}/dns-records" "$body"; then
+ if [ "$_muumuu_code" = "201" ]; then
+ _info "TXT record added successfully"
+ return 0
+ fi
+ fi
+
+ _err "Failed to add TXT record (HTTP ${_muumuu_code})"
+ return 1
+}
+
+dns_muumuu_rm() {
+ fulldomain="$(echo "$1" | _lower_case)"
+ txtvalue="$2"
+
+ _info "Using muumuu-domain.com DNS API"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ MUUMUU_PAT="${MUUMUU_PAT:-$(_readaccountconf_mutable MUUMUU_PAT)}"
+ if [ -z "$MUUMUU_PAT" ]; then
+ _err "MUUMUU_PAT is not set."
+ return 1
+ fi
+
+ if ! _muumuu_get_root "$fulldomain"; then
+ _err "Unable to find the root domain for $fulldomain"
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+
+ _info "Looking up TXT record for ${fulldomain}"
+ if ! _muumuu_rest GET "/me/domains/${_domain_id}/dns-records?type=TXT&fqdn=${fulldomain}."; then
+ _err "Failed to list TXT records"
+ return 1
+ fi
+
+ record_id=$(echo "$response" | _egrep_o "\"id\":[0-9]+[^}]*\"value\":\"${txtvalue}\"" | _egrep_o "\"id\":[0-9]+" | _head_n 1 | cut -d: -f2)
+ if [ -z "$record_id" ]; then
+ _info "TXT record not found, nothing to remove"
+ return 0
+ fi
+ _debug record_id "$record_id"
+
+ if _muumuu_rest DELETE "/me/domains/${_domain_id}/dns-records/${record_id}"; then
+ if [ "$_muumuu_code" = "204" ]; then
+ _info "TXT record deleted successfully"
+ return 0
+ fi
+ fi
+
+ _err "Failed to delete TXT record (HTTP ${_muumuu_code})"
+ return 1
+}
+
+#################### Private functions below ##################################
+
+# _acme-challenge.www.example.com
+# sets:
+# _domain_id MU00000001
+# _sub_domain _acme-challenge.www
+# _domain example.com
+_muumuu_get_root() {
+ domain="$1"
+ i=1
+ p=0
+ h=""
+ while true; do
+ h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+ if [ -z "$h" ]; then
+ return 1
+ fi
+ if ! _muumuu_rest GET "/me/domains?fqdn=${h}&page-size=1"; then
+ return 1
+ fi
+ if [ "$_muumuu_code" = "401" ] || [ "$_muumuu_code" = "403" ]; then
+ _err "Authentication failed (HTTP ${_muumuu_code}). Check MUUMUU_PAT."
+ return 1
+ fi
+ if _contains "$response" "\"fqdn\":\"${h}\""; then
+ _domain_id=$(echo "$response" | _egrep_o "\"id\":\"MU[0-9]+\"" | _head_n 1 | cut -d: -f2 | tr -d '"')
+ _domain="$h"
+ if [ "$p" = "0" ]; then
+ _sub_domain=""
+ else
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ fi
+ return 0
+ fi
+ p="$i"
+ i=$(_math "$i" + 1)
+ done
+}
+
+_muumuu_rest() {
+ _muumuu_method="$1"
+ _muumuu_path="$2"
+ _muumuu_data="$3"
+ _muumuu_url="${MUUMUU_API}${_muumuu_path}"
+
+ export _H1="Authorization: Bearer ${MUUMUU_PAT}"
+ export _H2="Content-Type: application/json"
+ export _H3="Accept: application/json"
+ export _H4=""
+ export _H5=""
+
+ _secure_debug2 data "$_muumuu_data"
+
+ if [ "$_muumuu_method" = "GET" ]; then
+ response="$(_get "$_muumuu_url")"
+ else
+ response="$(_post "$_muumuu_data" "$_muumuu_url" "" "$_muumuu_method")"
+ fi
+ _muumuu_ret="$?"
+ _muumuu_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")"
+ _debug "HTTP code: ${_muumuu_code}"
+ _secure_debug2 response "$response"
+
+ if [ "$_muumuu_ret" != "0" ]; then
+ _err "Error accessing ${_muumuu_url}"
+ return 1
+ fi
+
+ response="$(printf "%s" "$response" | _normalizeJson)"
+ return 0
+}
diff --git a/dnsapi/dns_mythic_beasts.sh b/dnsapi/dns_mythic_beasts.sh
index 1529e1e7..a49ab8ab 100755
--- a/dnsapi/dns_mythic_beasts.sh
+++ b/dnsapi/dns_mythic_beasts.sh
@@ -186,7 +186,7 @@ _oauth2() {
_oauth2_std() {
# HTTP Basic Authentication
_H1="Authorization: Basic $(echo "$MB_AK:$MB_AS" | _base64)"
- _H2="Accepts: application/json"
+ _H2="Accept: application/json"
export _H1 _H2
body="grant_type=client_credentials"
@@ -210,7 +210,7 @@ _oauth2_std() {
}
_oauth2_github() {
- _H1="Accepts: application/json"
+ _H1="Accept: application/json"
export _H1
body="{\"login\":{\"handle\":\"$MB_AK\",\"pass\":\"$MB_AS\",\"floating\":1}}"
@@ -241,7 +241,7 @@ _mb_rest() {
fi
_H1="Authorization: Bearer $MB_TK"
- _H2="Accepts: application/json"
+ _H2="Accept: application/json"
export _H1 _H2
if [ "$data" ] || [ "$m" = "POST" ] || [ "$m" = "PUT" ] || [ "$m" = "DELETE" ]; then
# body url [needbase64] [POST|PUT|DELETE] [ContentType]
diff --git a/dnsapi/dns_namecheap.sh b/dnsapi/dns_namecheap.sh
index 5527b357..7035640b 100755
--- a/dnsapi/dns_namecheap.sh
+++ b/dnsapi/dns_namecheap.sh
@@ -104,6 +104,9 @@ _get_root_by_getList() {
return 1
fi
+ _namecheap_domain_list=$(echo "$response" | _egrep_o ']*')
+ _debug2 domain_list "$_namecheap_domain_list"
+
i=2
p=1
@@ -120,7 +123,7 @@ _get_root_by_getList() {
return 1
fi
- if ! _contains "$response" "$h"; then
+ if ! _namecheap_is_our_dns "$h"; then
_debug "$h not found"
else
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
@@ -133,6 +136,29 @@ _get_root_by_getList() {
return 1
}
+#Usage: _namecheap_is_our_dns
+#Succeeds only when domains.getList listed exactly AND that entry is
+#served by Namecheap's own DNS. A domain parked on Namecheap's webhosting DNS
+#is listed with IsOurDNS="false", and every dns.getHosts/setHosts call against
+#it is refused with error 2030288 "not using proper DNS servers". Accepting
+#such a domain as the root zone hides a subdomain that IS delegated to
+#Namecheap DNS and that the getHosts probe below would have found.
+#https://github.com/acmesh-official/acme.sh/issues/7178
+_namecheap_is_our_dns() {
+ _namecheap_entry=$(echo "$_namecheap_domain_list" | grep -F " Name=\"$1\"" | _head_n 1)
+ if [ -z "$_namecheap_entry" ]; then
+ return 1
+ fi
+
+ _namecheap_ourdns=$(echo "$_namecheap_entry" | _egrep_o ' IsOurDNS="[^"]*' | cut -d '"' -f 2)
+ _debug2 "$1 IsOurDNS" "$_namecheap_ourdns"
+
+ if [ "$_namecheap_ourdns" = "true" ]; then
+ return 0
+ fi
+ return 1
+}
+
_get_root_by_getHosts() {
i=100
p=99
@@ -264,8 +290,16 @@ _set_namecheap_TXT() {
_debug hosts "$hosts"
if [ -z "$hosts" ]; then
- _err "Hosts not found"
- return 1
+ # An empty host list is only acceptable when the API positively confirms
+ # a successful getHosts reply: setHosts below REPLACES all records, so
+ # proceeding on a malformed/unparsed response would wipe the whole zone.
+ # https://github.com/acmesh-official/acme.sh/issues/6963
+ if _contains "$response" "Status=\"OK\"" && _contains "$response" "DomainDNSGetHostsResult"; then
+ _debug "No existing host records, adding the TXT record as the first one"
+ else
+ _err "Hosts not found"
+ return 1
+ fi
fi
_namecheap_reset_hostList
diff --git a/dnsapi/dns_namecom.sh b/dnsapi/dns_namecom.sh
index 1062c849..bd7da0c2 100755
--- a/dnsapi/dns_namecom.sh
+++ b/dnsapi/dns_namecom.sh
@@ -15,7 +15,7 @@ Namecom_API="https://api.name.com/v4"
#Usage: dns_namecom_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_namecom_add() {
- fulldomain=$1
+ fulldomain=$(_idn "$1")
txtvalue=$2
Namecom_Username="${Namecom_Username:-$(_readaccountconf_mutable Namecom_Username)}"
@@ -68,7 +68,7 @@ dns_namecom_add() {
#Usage: fulldomain txtvalue
#Remove the txt record after validation.
dns_namecom_rm() {
- fulldomain=$1
+ fulldomain=$(_idn "$1")
txtvalue=$2
Namecom_Username="${Namecom_Username:-$(_readaccountconf_mutable Namecom_Username)}"
@@ -153,10 +153,9 @@ _namecom_get_root() {
i=2
p=1
- if ! _namecom_rest GET "domains"; then
- return 1
- fi
-
+ # Probe each candidate with GetDomain (GET /v4/domains/{domainName}) instead
+ # of listing all domains: the list is paginated at 1000 domains per page, so
+ # larger accounts never found their domain on the first page.
# Need to exclude the last field (tld)
numfields=$(echo "$domain" | _egrep_o "\." | wc -l)
while [ "$i" -le "$numfields" ]; do
@@ -166,7 +165,7 @@ _namecom_get_root() {
return 1
fi
- if _contains "$response" "$host"; then
+ if _namecom_rest GET "domains/$host" && _contains "$response" "\"domainName\":\"$host\""; then
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
_domain="$host"
return 0
diff --git a/dnsapi/dns_namesilo.sh b/dnsapi/dns_namesilo.sh
index 5d47a59a..df5871cf 100755
--- a/dnsapi/dns_namesilo.sh
+++ b/dnsapi/dns_namesilo.sh
@@ -65,7 +65,7 @@ dns_namesilo_rm() {
if _namesilo_rest GET "dnsListRecords?version=1&type=xml&key=$Namesilo_Key&domain=$_domain"; then
retcode=$(printf "%s\n" "$response" | _egrep_o "300")
if [ "$retcode" ]; then
- _record_id=$(echo "$response" | _egrep_o "([^<]*)TXT$fulldomain" | _egrep_o "([^<]*)" | sed -r "s/([^<]*)<\/record_id>/\1/" | tail -n 1)
+ _record_id=$(echo "$response" | _egrep_o "([^<]*)TXT$_sub_domain$txtvalue" | _egrep_o "([^<]*)" | sed -r "s/([^<]*)<\/record_id>/\1/" | tail -n 1)
_debug _record_id "$_record_id"
if [ "$_record_id" ]; then
_info "Successfully retrieved the record id for ACME challenge."
diff --git a/dnsapi/dns_netcup.sh b/dnsapi/dns_netcup.sh
index 8609adf6..3b291854 100644
--- a/dnsapi/dns_netcup.sh
+++ b/dnsapi/dns_netcup.sh
@@ -33,9 +33,11 @@ dns_netcup_add() {
exit=$(echo "$fulldomain" | tr -dc '.' | wc -c)
exit=$(_math "$exit" + 1)
i=$exit
+ _nc_last=$(_nc_lastlevel "$i")
+ _nc_found=""
while
- [ "$exit" -gt 0 ]
+ [ "$exit" -ge "$_nc_last" ]
do
tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit")
if [ "$(_math "$i" - "$exit")" -eq 0 ]; then
@@ -51,12 +53,18 @@ dns_netcup_add() {
_err "$msg"
return 1
else
+ _nc_found=1
break
fi
fi
fi
exit=$(_math "$exit" - 1)
done
+ if [ -z "$_nc_found" ]; then
+ _err "$msg"
+ _nc_nozone "$fulldomain"
+ return 1
+ fi
logout
}
@@ -70,9 +78,11 @@ dns_netcup_rm() {
exit=$(_math "$exit" + 1)
i=$exit
rec=""
+ _nc_last=$(_nc_lastlevel "$i")
+ _nc_found=""
while
- [ "$exit" -gt 0 ]
+ [ "$exit" -ge "$_nc_last" ]
do
tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit")
if [ "$(_math "$i" - "$exit")" -eq 0 ]; then
@@ -89,12 +99,18 @@ dns_netcup_rm() {
_err "$msg"
return 1
else
+ _nc_found=1
break
fi
fi
fi
exit=$(_math "$exit" - 1)
done
+ if [ -z "$_nc_found" ]; then
+ _err "$msg"
+ _nc_nozone "$fulldomain"
+ return 1
+ fi
ida=0000
idv=0001
@@ -125,6 +141,27 @@ dns_netcup_rm() {
logout
}
+# The zone is looked up by walking the challenge name from the right, one
+# label at a time. The leftmost label is the challenge prefix, so the full
+# name itself can never be a zone: asking netcup for it only returns 4013
+# "Validation Error", which would then mask the real 5028 "zone could not be
+# found". Stop one label short, unless the name is too short to have a
+# challenge prefix at all (manual invocation).
+# levels
+_nc_lastlevel() {
+ if [ "$1" -ge 3 ]; then
+ echo 2
+ else
+ echo 1
+ fi
+}
+
+# fulldomain
+_nc_nozone() {
+ _err "No DNS zone for $1 was found at netcup."
+ _err "Check that the domain belongs to the account of the configured NC_CID and that its DNS is hosted at netcup."
+}
+
_login() {
tmp=$(_post "{\"action\": \"login\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apipassword\": \"$NC_Apipw\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST")
sid=$(echo "$tmp" | tr '{}' '\n' | grep apisessionid | cut -d '"' -f 4)
diff --git a/dnsapi/dns_nexdns.sh b/dnsapi/dns_nexdns.sh
new file mode 100755
index 00000000..e4447c0e
--- /dev/null
+++ b/dnsapi/dns_nexdns.sh
@@ -0,0 +1,244 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_nexdns_info='NexDNS
+Site: nexdns.tech
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_nexdns
+Options:
+ NEXDNS_Token API token. Can be created at https://nexdns.tech/settings/api-keys
+ NEXDNS_Api API base url. Default "https://api.nexdns.tech/v1". Optional.
+Issues: github.com/acmesh-official/acme.sh/issues/7179
+Author: NexDNS
+'
+
+NEXDNS_Api_Default="https://api.nexdns.tech/v1"
+
+######## Public functions #####################
+
+#Usage: dns_nexdns_add _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_nexdns_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ if ! _nexdns_init; then
+ return 1
+ fi
+
+ _saveaccountconf_mutable NEXDNS_Token "$NEXDNS_Token"
+ if [ "$NEXDNS_Api" != "$NEXDNS_Api_Default" ]; then
+ _saveaccountconf_mutable NEXDNS_Api "$NEXDNS_Api"
+ else
+ _clearaccountconf_mutable NEXDNS_Api
+ fi
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "Cannot find the zone of $fulldomain in this NexDNS account."
+ return 1
+ fi
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+ _debug _domain_id "$_domain_id"
+
+ _info "Adding the TXT record for $fulldomain"
+ if ! _nexdns_rest POST "zones/$_domain_id/records" "{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"ttl\":120}"; then
+ return 1
+ fi
+
+ _info "The TXT record has been added."
+ return 0
+}
+
+#Usage: dns_nexdns_rm _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_nexdns_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ if ! _nexdns_init; then
+ return 1
+ fi
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "Cannot find the zone of $fulldomain in this NexDNS account."
+ return 1
+ fi
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+ _debug _domain_id "$_domain_id"
+
+ _info "Removing the TXT record for $fulldomain"
+ if ! _nexdns_rest GET "zones/$_domain_id/records?type=TXT&name=$_sub_domain"; then
+ return 1
+ fi
+
+ #All the challenge records share one name and one type, so the value is the
+ #only thing that tells them apart. A certificate covering example.com and
+ #*.example.com puts two of them at the same name at the same time.
+ _record_id="$(echo "$response" | tr '{' "\n" | grep -- "$txtvalue" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)"
+ _debug _record_id "$_record_id"
+
+ if [ -z "$_record_id" ]; then
+ _info "The TXT record is already gone, nothing to remove."
+ return 0
+ fi
+
+ if ! _nexdns_rest DELETE "zones/$_domain_id/records/$_record_id"; then
+ return 1
+ fi
+
+ _info "The TXT record has been removed."
+ return 0
+}
+
+#################### Private functions below ##################################
+
+#Reads the token and the api url, and applies the default url.
+_nexdns_init() {
+ NEXDNS_Token="${NEXDNS_Token:-$(_readaccountconf_mutable NEXDNS_Token)}"
+ NEXDNS_Api="${NEXDNS_Api:-$(_readaccountconf_mutable NEXDNS_Api)}"
+
+ if [ -z "$NEXDNS_Token" ]; then
+ _err "You have not set NEXDNS_Token yet."
+ _err "Create one at https://nexdns.tech/settings/api-keys, on a plan that includes API access, then:"
+ _err "export NEXDNS_Token=\"your-api-token\""
+ return 1
+ fi
+
+ if [ -z "$NEXDNS_Api" ]; then
+ NEXDNS_Api="$NEXDNS_Api_Default"
+ fi
+ #A trailing slash would make every request path begin with a double slash.
+ NEXDNS_Api="$(echo "$NEXDNS_Api" | sed 's|/*$||')"
+ _debug NEXDNS_Api "$NEXDNS_Api"
+
+ return 0
+}
+
+#_acme-challenge.www.example.com
+#returns
+# _sub_domain=_acme-challenge.www
+# _domain=example.com
+# _domain_id=Zm9vYmFy
+_get_root() {
+ domain=$1
+ i=1
+ p=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 ! _nexdns_rest GET "zones?search=$h&per_page=100"; then
+ return 1
+ fi
+
+ #search matches on a substring, so the page can also hold zones that merely
+ #contain h. Take the id of the one whose name is exactly h.
+ _domain_id="$(echo "$response" | tr '{' "\n" | grep "\"name\":\"$h\"" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)"
+ if [ "$_domain_id" ]; then
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ _domain=$h
+ return 0
+ fi
+
+ p=$i
+ i=$(_math "$i" + 1)
+ done
+}
+
+#Usage: _nexdns_rest GET|POST|DELETE path [body] [attempt]
+_nexdns_rest() {
+ m=$1
+ ep=$2
+ data=$3
+ attempt=${4:-1}
+ _debug "$ep"
+
+ export _H1="Authorization: Bearer $NEXDNS_Token"
+ export _H2="Content-Type: application/json"
+ export _H3="Accept: application/json"
+
+ if [ "$m" = "GET" ]; then
+ response="$(_get "$NEXDNS_Api/$ep")"
+ else
+ _debug2 data "$data"
+ response="$(_post "$data" "$NEXDNS_Api/$ep" "" "$m" "application/json")"
+ fi
+
+ if [ "$?" != "0" ]; then
+ _err "error $ep"
+ return 1
+ fi
+
+ #A single certificate costs a handful of requests, but a renewal sweep over
+ #many of them meets the account's per-minute budget, and that run is
+ #unattended. Retry-After is treated as a floor: an api may report the time one
+ #token needs at an average rate and name a second when nothing frees for a
+ #minute, so the wait grows on its own across attempts.
+ if [ "$(grep "^HTTP" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" = "429" ]; then
+ if [ "$attempt" -ge 4 ]; then
+ _err "$m $ep failed: rate limited, and the wait budget is spent"
+ return 1
+ fi
+
+ _retry_after="$(grep -i "^Retry-After" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d : -f 2 | tr -d " \r\n")"
+ _backoff="$(_math "$attempt" \* 15)"
+ #The header may also carry an http date. Anything but a plain count of
+ #seconds falls through to the backoff rather than being parsed: guessing
+ #wrong about a date is worse than waiting a known interval, and comparing a
+ #date numerically would abort the hook outright.
+ case "$_retry_after" in
+ "" | *[!0-9]*) _retry_after="$_backoff" ;;
+ *)
+ if [ "$_retry_after" -lt "$_backoff" ]; then
+ _retry_after="$_backoff"
+ fi
+ ;;
+ esac
+
+ #A wait longer than this is a refusal rather than a schedule, and sleeping
+ #it out would hold the hook for the length of the window. Hand the run back
+ #instead, so the next cron pass picks it up.
+ if [ "$_retry_after" -gt 120 ]; then
+ _err "$m $ep failed: rate limited for ${_retry_after}s, longer than this hook will wait"
+ return 1
+ fi
+
+ _info "Rate limited by the NexDNS API; retrying in $_retry_after seconds."
+ _sleep "$_retry_after"
+
+ _nexdns_rest "$m" "$ep" "$data" "$(_math "$attempt" + 1)"
+ return $?
+ fi
+
+ #Whitespace between a key and its value would defeat every match made on the
+ #body, here and in the callers.
+ response="$(echo "$response" | _normalizeJson)"
+ _debug2 response "$response"
+
+ #The status line decides success, not the body: a delete answers 204 with no
+ #body at all, and a record whose own content contains "error": would otherwise
+ #turn a stored value into a reported failure. The body is read only for the
+ #message once the status says the request was rejected.
+ _code="$(grep "^HTTP" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ _debug2 _code "$_code"
+ case "$_code" in
+ "" | 2*)
+ return 0
+ ;;
+ esac
+
+ #A rejected request carries {"error":{"code":..,"message":..}}, so say what the
+ #api says went wrong.
+ _message="$(echo "$response" | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)"
+ if [ -z "$_message" ]; then
+ _message="status $_code"
+ fi
+ _err "$m $ep failed: $_message"
+
+ return 1
+}
diff --git a/dnsapi/dns_njalla.sh b/dnsapi/dns_njalla.sh
index c410447d..6ce51380 100644
--- a/dnsapi/dns_njalla.sh
+++ b/dnsapi/dns_njalla.sh
@@ -98,7 +98,7 @@ dns_njalla_rm() {
echo "$records" | while read -r record; do
record_name=$(echo "$record" | _egrep_o "\"name\":\s?\"[^\"]*\"" | cut -d : -f 2 | tr -d " " | tr -d \")
record_content=$(echo "$record" | _egrep_o "\"content\":\s?\"[^\"]*\"" | cut -d : -f 2 | tr -d " " | tr -d \")
- record_id=$(echo "$record" | _egrep_o "\"id\":\s?[0-9]+" | cut -d : -f 2 | tr -d " " | tr -d \")
+ record_id=$(echo "$record" | _egrep_o "\"id\":\s?\"?[^\",}]*" | cut -d : -f 2 | tr -d " " | tr -d \")
if [ "$_sub_domain" = "$record_name" ]; then
if [ "$txtvalue" = "$record_content" ]; then
_debug "record_id" "$record_id"
diff --git a/dnsapi/dns_oci.sh b/dnsapi/dns_oci.sh
index c76a4565..e1aa3dd9 100644
--- a/dnsapi/dns_oci.sh
+++ b/dnsapi/dns_oci.sh
@@ -115,12 +115,15 @@ _oci_config() {
_clearaccountconf_mutable OCI_CLI_PROFILE
fi
- OCI_CLI_TENANCY="${OCI_CLI_TENANCY:-$(_readaccountconf_mutable OCI_CLI_TENANCY)}"
+ if [ -z "$OCI_CLI_TENANCY" ] && [ -f "$OCI_CLI_CONFIG_FILE" ]; then
+ _debug "Reading OCI_CLI_TENANCY value from: $OCI_CLI_CONFIG_FILE"
+ OCI_CLI_TENANCY=$(_readini "$OCI_CLI_CONFIG_FILE" tenancy "$OCI_CLI_PROFILE")
+ fi
+ if [ -z "$OCI_CLI_TENANCY" ]; then
+ OCI_CLI_TENANCY=$(_readaccountconf_mutable OCI_CLI_TENANCY)
+ fi
if [ "$OCI_CLI_TENANCY" ]; then
_saveaccountconf_mutable OCI_CLI_TENANCY "$OCI_CLI_TENANCY"
- elif [ -f "$OCI_CLI_CONFIG_FILE" ]; then
- _debug "Reading OCI_CLI_TENANCY value from: $OCI_CLI_CONFIG_FILE"
- OCI_CLI_TENANCY="${OCI_CLI_TENANCY:-$(_readini "$OCI_CLI_CONFIG_FILE" tenancy "$OCI_CLI_PROFILE")}"
fi
if [ -z "$OCI_CLI_TENANCY" ]; then
@@ -128,41 +131,47 @@ _oci_config() {
return 1
fi
- OCI_CLI_USER="${OCI_CLI_USER:-$(_readaccountconf_mutable OCI_CLI_USER)}"
+ if [ -z "$OCI_CLI_USER" ] && [ -f "$OCI_CLI_CONFIG_FILE" ]; then
+ _debug "Reading OCI_CLI_USER value from: $OCI_CLI_CONFIG_FILE"
+ OCI_CLI_USER=$(_readini "$OCI_CLI_CONFIG_FILE" user "$OCI_CLI_PROFILE")
+ fi
+ if [ -z "$OCI_CLI_USER" ]; then
+ OCI_CLI_USER=$(_readaccountconf_mutable OCI_CLI_USER)
+ fi
if [ "$OCI_CLI_USER" ]; then
_saveaccountconf_mutable OCI_CLI_USER "$OCI_CLI_USER"
- elif [ -f "$OCI_CLI_CONFIG_FILE" ]; then
- _debug "Reading OCI_CLI_USER value from: $OCI_CLI_CONFIG_FILE"
- OCI_CLI_USER="${OCI_CLI_USER:-$(_readini "$OCI_CLI_CONFIG_FILE" user "$OCI_CLI_PROFILE")}"
fi
if [ -z "$OCI_CLI_USER" ]; then
_err "Error: unable to read OCI_CLI_USER from config file or environment variable."
return 1
fi
- OCI_CLI_REGION="${OCI_CLI_REGION:-$(_readaccountconf_mutable OCI_CLI_REGION)}"
+ if [ -z "$OCI_CLI_REGION" ] && [ -f "$OCI_CLI_CONFIG_FILE" ]; then
+ _debug "Reading OCI_CLI_REGION value from: $OCI_CLI_CONFIG_FILE"
+ OCI_CLI_REGION=$(_readini "$OCI_CLI_CONFIG_FILE" region "$OCI_CLI_PROFILE")
+ fi
+ if [ -z "$OCI_CLI_REGION" ]; then
+ OCI_CLI_REGION=$(_readaccountconf_mutable OCI_CLI_REGION)
+ fi
if [ "$OCI_CLI_REGION" ]; then
_saveaccountconf_mutable OCI_CLI_REGION "$OCI_CLI_REGION"
- elif [ -f "$OCI_CLI_CONFIG_FILE" ]; then
- _debug "Reading OCI_CLI_REGION value from: $OCI_CLI_CONFIG_FILE"
- OCI_CLI_REGION="${OCI_CLI_REGION:-$(_readini "$OCI_CLI_CONFIG_FILE" region "$OCI_CLI_PROFILE")}"
fi
if [ -z "$OCI_CLI_REGION" ]; then
_err "Error: unable to read OCI_CLI_REGION from config file or environment variable."
return 1
fi
- OCI_CLI_KEY="${OCI_CLI_KEY:-$(_readaccountconf_mutable OCI_CLI_KEY)}"
- if [ -z "$OCI_CLI_KEY" ]; then
- _clearaccountconf_mutable OCI_CLI_KEY
- OCI_CLI_KEY_FILE="${OCI_CLI_KEY_FILE:-$(_readini "$OCI_CLI_CONFIG_FILE" key_file "$OCI_CLI_PROFILE")}"
- if [ "$OCI_CLI_KEY_FILE" ] && [ -f "$OCI_CLI_KEY_FILE" ]; then
- _debug "Reading OCI_CLI_KEY value from: $OCI_CLI_KEY_FILE"
- OCI_CLI_KEY=$(_base64 <"$OCI_CLI_KEY_FILE")
- _saveaccountconf_mutable OCI_CLI_KEY "$OCI_CLI_KEY"
- fi
- else
+ if [ -z "$OCI_CLI_KEY_FILE" ] && [ -f "$OCI_CLI_CONFIG_FILE" ]; then
+ OCI_CLI_KEY_FILE=$(_readini "$OCI_CLI_CONFIG_FILE" key_file "$OCI_CLI_PROFILE")
+ fi
+ if [ "$OCI_CLI_KEY" ]; then
_saveaccountconf_mutable OCI_CLI_KEY "$OCI_CLI_KEY"
+ elif [ "$OCI_CLI_KEY_FILE" ] && [ -f "$OCI_CLI_KEY_FILE" ]; then
+ _debug "Reading OCI_CLI_KEY value from: $OCI_CLI_KEY_FILE"
+ OCI_CLI_KEY=$(_base64 <"$OCI_CLI_KEY_FILE")
+ _saveaccountconf_mutable OCI_CLI_KEY "$OCI_CLI_KEY"
+ else
+ OCI_CLI_KEY=$(_readaccountconf_mutable OCI_CLI_KEY)
fi
if [ -z "$OCI_CLI_KEY_FILE" ] && [ -z "$OCI_CLI_KEY" ]; then
diff --git a/dnsapi/dns_ovh.sh b/dnsapi/dns_ovh.sh
index 9f2cd23f..df2b184d 100755
--- a/dnsapi/dns_ovh.sh
+++ b/dnsapi/dns_ovh.sh
@@ -224,7 +224,7 @@ _ovh_authentication() {
_H3=""
_H4=""
- _ovhdata='{"accessRules": [{"method": "GET","path": "/auth/time"},{"method": "GET","path": "/domain"},{"method": "GET","path": "/domain/zone/*"},{"method": "GET","path": "/domain/zone/*/record"},{"method": "POST","path": "/domain/zone/*/record"},{"method": "POST","path": "/domain/zone/*/refresh"},{"method": "PUT","path": "/domain/zone/*/record/*"},{"method": "DELETE","path": "/domain/zone/*/record/*"}],"redirection":"'$ovh_success'"}'
+ _ovhdata='{"accessRules": [{"method": "GET","path": "/auth/time"},{"method": "GET","path": "/domain"},{"method": "GET","path": "/domain/zone/*"},{"method": "GET","path": "/domain/zone/*/record"},{"method": "GET","path": "/domain/zone/*/record/*"},{"method": "POST","path": "/domain/zone/*/record"},{"method": "POST","path": "/domain/zone/*/refresh"},{"method": "PUT","path": "/domain/zone/*/record/*"},{"method": "DELETE","path": "/domain/zone/*/record/*"}],"redirection":"'$ovh_success'"}'
response="$(_post "$_ovhdata" "$OVH_API/auth/credential")"
_debug3 response "$response"
diff --git a/dnsapi/dns_pdns.sh b/dnsapi/dns_pdns.sh
index ec19ad25..72a58af0 100755
--- a/dnsapi/dns_pdns.sh
+++ b/dnsapi/dns_pdns.sh
@@ -50,6 +50,9 @@ dns_pdns_add() {
PDNS_Ttl="$DEFAULT_PDNS_TTL"
fi
+ # Ensure PDNS_Url has no trailing slash ('/')
+ PDNS_Url="${PDNS_Url%/}"
+
#save the api addr and key to the account conf file.
_saveaccountconf_mutable PDNS_Url "$PDNS_Url"
_saveaccountconf_mutable PDNS_ServerId "$PDNS_ServerId"
@@ -186,19 +189,23 @@ _get_root() {
domain=$1
i=1
- if _pdns_rest "GET" "/api/v1/servers/$PDNS_ServerId/zones"; then
- _zones_response=$(echo "$response" | _normalizeJson)
- fi
-
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
- if _contains "$_zones_response" "\"name\":\"$h.\""; then
- _domain="$h."
- if [ -z "$h" ]; then
- _domain="=2E"
+ # Probe each candidate zone with the server-side name filter instead of
+ # listing every zone: with large installations (100k zones) the
+ # unfiltered list takes minutes. Servers that ignore the parameter
+ # return the full list, which the check below still handles.
+ # https://doc.powerdns.com/authoritative/http-api/zone.html
+ if _pdns_rest "GET" "/api/v1/servers/$PDNS_ServerId/zones?zone=$h."; then
+ _zones_response=$(echo "$response" | _normalizeJson)
+ if _contains "$_zones_response" "\"name\":\"$h.\""; then
+ _domain="$h."
+ if [ -z "$h" ]; then
+ _domain="=2E"
+ fi
+ return 0
fi
- return 0
fi
if [ -z "$h" ]; then
diff --git a/dnsapi/dns_pleskxml.sh b/dnsapi/dns_pleskxml.sh
index 465bcc60..176f329d 100644
--- a/dnsapi/dns_pleskxml.sh
+++ b/dnsapi/dns_pleskxml.sh
@@ -151,8 +151,8 @@ dns_pleskxml_rm() {
# Extracting the id of the TXT record for the full domain (NOT case-sensitive) and corresponding value
recid="$(
_value "$reclist" |
- grep -i "${fulldomain}." |
- grep "${txtvalue}" |
+ grep -Fi "${fulldomain}." |
+ grep -F "${txtvalue}" |
sed 's/^.*\([0-9]\{1,\}\)<\/id>.*$/\1/'
)"
@@ -419,7 +419,7 @@ _pleskxml_get_root_domain() {
_debug "Checking if '$root_domain_name' is managed by the Plesk server..."
- root_domain_id="$(_value "$output" | grep "$root_domain_name" | _head_n 1 | sed 's/^.*\([0-9]\{1,\}\)<\/id>.*$/\1/')"
+ root_domain_id="$(_value "$output" | grep -F "$root_domain_name" | _head_n 1 | sed 's/^.*\([0-9]\{1,\}\)<\/id>.*$/\1/')"
if [ -n "$root_domain_id" ]; then
# Found a match
diff --git a/dnsapi/dns_poweradmin.sh b/dnsapi/dns_poweradmin.sh
new file mode 100644
index 00000000..a4c81835
--- /dev/null
+++ b/dnsapi/dns_poweradmin.sh
@@ -0,0 +1,238 @@
+#!/usr/bin/env sh
+
+# shellcheck disable=SC2034
+
+# Credits to the authors of dnsapi/dns_pdns.sh as this reuses much of that code.
+
+dns_poweradmin_info='Poweradmin API
+Site: https://www.poweradmin.org/
+Docs: https://github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_poweradmin
+Options:
+POWERADMIN_URL API URL (with scheme). E.g. "https://poweradmin.example.com" or "http://192.168.0.10:8080"
+POWERADMIN_API_KEY API Token "pwa_xxxx"
+POWERADMIN_API_VERSION Optionally override Poweradmin API version.
+Issues: https://github.com/acmesh-official/acme.sh/issues/6912
+Author: Jakob Næss
+'
+
+######## Public functions ####################
+
+# Usage: dns_poweradmin_add _acme-challenge.www.domain.com "123456789ABCDEF"
+# fulldomain
+# txtvalue
+dns_poweradmin_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ POWERADMIN_URL="${POWERADMIN_URL:-$(_readaccountconf_mutable POWERADMIN_URL)}"
+ POWERADMIN_API_KEY="${POWERADMIN_API_KEY:-$(_readaccountconf_mutable POWERADMIN_API_KEY)}"
+ POWERADMIN_API_VERSION="${POWERADMIN_API_VERSION:-$(_readaccountconf_mutable POWERADMIN_API_VERSION)}"
+ POWERADMIN_API_VERSION="${POWERADMIN_API_VERSION:-2}"
+
+ if [ -z "$POWERADMIN_URL" ]; then
+ POWERADMIN_URL=""
+ _err "You didn't specify Poweradmin URL."
+ _err "Please set POWERADMIN_URL and try again."
+ return 1
+ fi
+
+ if [ -z "$POWERADMIN_API_KEY" ]; then
+ POWERADMIN_API_KEY=""
+ _err "You didn't specify Poweradmin token."
+ _err "Please set POWERADMIN_API_KEY and try again."
+ return 1
+ fi
+
+ # Save the api addr, key, and version to the account conf file.
+ _saveaccountconf_mutable POWERADMIN_URL "$POWERADMIN_URL"
+ _saveaccountconf_mutable POWERADMIN_API_KEY "$POWERADMIN_API_KEY"
+ _saveaccountconf_mutable POWERADMIN_API_VERSION "$POWERADMIN_API_VERSION"
+
+ _debug "Detect root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain"
+ return 1
+ fi
+
+ _debug _domain "$_domain"
+ _debug _zone_id "$_zone_id"
+
+ if ! _set_record "$fulldomain" "$txtvalue"; then
+ return 1
+ fi
+
+ return 0
+}
+
+# Usage: dns_poweradmin_rm _acme-challenge.www.domain.com "123456789ABCDEF"
+# fulldomain
+# txtvalue
+dns_poweradmin_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ POWERADMIN_URL="${POWERADMIN_URL:-$(_readaccountconf_mutable POWERADMIN_URL)}"
+ POWERADMIN_API_KEY="${POWERADMIN_API_KEY:-$(_readaccountconf_mutable POWERADMIN_API_KEY)}"
+ POWERADMIN_API_VERSION="${POWERADMIN_API_VERSION:-$(_readaccountconf_mutable POWERADMIN_API_VERSION)}"
+ POWERADMIN_API_VERSION="${POWERADMIN_API_VERSION:-2}"
+
+ _debug "Detect root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain"
+ return 1
+ fi
+
+ _debug _domain "$_domain"
+ _debug _zone_id "$_zone_id"
+
+ if ! _rm_record "$fulldomain" "$txtvalue"; then
+ return 1
+ fi
+
+ return 0
+}
+
+######## Private functions below #####################
+
+_set_record() {
+ _info "Adding TXT record"
+ full=$1
+ new_challenge=$2
+
+ data='{"name":"'$full'","type":"TXT","content":"'$new_challenge'","ttl":60}'
+
+ if ! _poweradmin_rest "POST" "/api/v${POWERADMIN_API_VERSION}/zones/$_zone_id/records" "$data" "application/json"; then
+ _err "Failed to add TXT record"
+ return 1
+ fi
+
+ return 0
+}
+
+_rm_record() {
+ _info "Remove TXT record"
+ full=$1
+ txtvalue=$2
+
+ if ! _poweradmin_rest "GET" "/api/v${POWERADMIN_API_VERSION}/zones/$_zone_id/records"; then
+ _err "Failed to retrieve records"
+ return 1
+ fi
+
+ # The API returns: {"success":true,"data":[{"id":..., "name":"...", "type":"TXT", "content":"...", ...}]}
+ _txt_record_obj=$(
+ printf '%s\n' "$response" |
+ sed 's/^.*"data":\[//; s/\],"message":.*$//' |
+ awk '{ gsub(/},{/, "}\n{"); print }' |
+ grep -F "\"name\":\"$full\"" |
+ grep -F "\"type\":\"TXT\"" |
+ grep -F "\"content\":\"$txtvalue\"" |
+ _head_n 1
+ )
+
+ if [ -z "$_txt_record_obj" ]; then
+ _info "TXT record not found for $full with content $txtvalue"
+ return 0
+ fi
+
+ record_id=$(printf '%s\n' "$_txt_record_obj" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p' | _head_n 1)
+ record_type=$(printf '%s\n' "$_txt_record_obj" | sed -n 's/.*"type":"\([^"]*\)".*/\1/p' | _head_n 1)
+ record_name=$(printf '%s\n' "$_txt_record_obj" | sed -n 's/.*"name":"\([^"]*\)".*/\1/p' | _head_n 1)
+ record_content=$(printf '%s\n' "$_txt_record_obj" | sed -n 's/.*"content":"\([^"]*\)".*/\1/p' | _head_n 1)
+
+ _debug2 "_txt_record_obj=$_txt_record_obj"
+ _debug2 "record id: $record_id"
+ _debug2 "record type: $record_type"
+ _debug2 "record name: $record_name"
+ _debug2 "record content: $record_content"
+
+ if [ "$record_type" != "TXT" ]; then
+ _err "Refusing to delete non-TXT record id=$record_id type=$record_type name=$full"
+ return 1
+ fi
+
+ if ! _poweradmin_rest "DELETE" "/api/v${POWERADMIN_API_VERSION}/zones/$_zone_id/records/$record_id"; then
+ _err "Failed to delete TXT record"
+ return 1
+ fi
+
+ _info "Record deleted successfully"
+ return 0
+}
+
+# _acme-challenge.www.domain.com
+# returns
+# _domain=domain.com
+# _zone_id=220
+_get_root() {
+ domain=$1
+ i=1
+
+ if ! _poweradmin_rest "GET" "/api/v${POWERADMIN_API_VERSION}/zones"; then
+ _err "Failed to retrieve zones"
+ return 1
+ fi
+
+ _zones_response="$response"
+
+ while true; do
+ h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+
+ if [ -z "$h" ]; then
+ _debug "Root domain not found for $domain"
+ return 1
+ fi
+
+ zone_obj=$(
+ printf '%s' "$_zones_response" |
+ sed 's/},{/}\n{/g' |
+ grep -F "\"name\":\"$h\"" |
+ _head_n 1
+ )
+
+ if [ -n "$zone_obj" ]; then
+ _zone_id=$(printf '%s' "$zone_obj" | _egrep_o '"id":[0-9][0-9]*' | _head_n 1 | cut -d: -f2)
+ _domain="$h"
+ _debug "Found zone: $_domain with id: $_zone_id"
+ return 0
+ fi
+
+ i=$(_math "$i" + 1)
+ done
+}
+
+_poweradmin_rest() {
+ method=$1
+ ep=$2
+ data=$3
+ ct=$4
+
+ export _H1="X-API-Key: $POWERADMIN_API_KEY"
+
+ if [ "$method" = "GET" ]; then
+ response="$(_get "$POWERADMIN_URL$ep")"
+ else
+ _debug "API call: $method $ep"
+ _debug "Content-Type: $ct"
+ _debug "Payload: $data"
+ response="$(_post "$data" "$POWERADMIN_URL$ep" "" "$method" "$ct")"
+ fi
+
+ # Clear _H1 variable
+ unset -v _H1
+
+ if [ "$?" != "0" ]; then
+ _err "API error on $method $ep"
+ _debug "Response: $response"
+ return 1
+ fi
+
+ if printf '%s' "$response" | grep -q '"success"[ ]*:[ ]*false'; then
+ _err "API reported failure on $method $ep"
+ _debug "Response: $response"
+ return 1
+ fi
+
+ _debug2 "API Response: $response"
+ return 0
+}
diff --git a/dnsapi/dns_rage4.sh b/dnsapi/dns_rage4.sh
index c27fbc5f..b9abff17 100755
--- a/dnsapi/dns_rage4.sh
+++ b/dnsapi/dns_rage4.sh
@@ -71,7 +71,7 @@ dns_rage4_rm() {
_debug "Getting txt records"
_rage4_rest "getrecords/?id=${_domain_id}"
- _record_id=$(echo "$response" | tr '{' '\n' | grep '"TXT"' | grep "\"$txtvalue" | sed -rn 's/.*"id":([[:digit:]]+),.*/\1/p')
+ _record_id=$(echo "$response" | tr '{' '\n' | grep '"TXT"' | grep "\"$txtvalue" | sed -n 's/.*"id":\([0-9][0-9]*\),.*/\1/p')
if [ -z "$_record_id" ]; then
_err "error retrieving the record_id of the new TXT record in order to delete it, got: '$_record_id'."
return 1
diff --git a/dnsapi/dns_regru.sh b/dnsapi/dns_regru.sh
index be5ae117..edf8b464 100644
--- a/dnsapi/dns_regru.sh
+++ b/dnsapi/dns_regru.sh
@@ -96,8 +96,8 @@ _get_root() {
for ITEM in ${domains_list}; do
IDN_ITEM=${ITEM}
- case "${domain}" in
- *${IDN_ITEM}*)
+ case ".${domain}" in
+ *.${IDN_ITEM}*)
_domain="$(_idn "${ITEM}")"
_debug _domain "${_domain}"
return 0
diff --git a/dnsapi/dns_rltx.sh b/dnsapi/dns_rltx.sh
new file mode 100644
index 00000000..065ac177
--- /dev/null
+++ b/dnsapi/dns_rltx.sh
@@ -0,0 +1,145 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_rltx_info='Realtox Media Cloudpanel DNS API
+Site: realtoxmedia.de
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_rltx
+Options:
+ RLTX_Key API Key
+ RLTX_OrganizationID Organization ID
+'
+
+######## Public functions #####################
+
+#Usage: dns_rltx_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_rltx_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ _info "Using Realtox Media Cloudpanel DNS API"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ if ! _rltx_init; then
+ return 1
+ fi
+
+ if ! _get_root "$fulldomain"; then
+ _err "Could not find matching DNS zone for $fulldomain"
+ return 1
+ fi
+
+ _debug _domain_id "$_domain_id"
+ _debug _domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+
+ data="{\"name\":\"$_sub_domain\",\"value\":\"$txtvalue\",\"ttl\":120}"
+ if ! _rltx_rest POST "domains/$_domain_id/dns/acme-txt" "$data"; then
+ _err "Add TXT record request failed"
+ return 1
+ fi
+ if _contains "$response" '"status":"added"'; then
+ _info "Added TXT record, OK"
+ return 0
+ fi
+ _err "Add TXT record failed: $response"
+ return 1
+}
+
+#Usage: fulldomain txtvalue
+#Remove the txt record after validation.
+dns_rltx_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ _info "Using Realtox Media Cloudpanel DNS API"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ if ! _rltx_init; then
+ return 1
+ fi
+
+ if ! _get_root "$fulldomain"; then
+ _err "Could not find matching DNS zone for $fulldomain"
+ return 1
+ fi
+
+ _debug _domain_id "$_domain_id"
+ _debug _domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+
+ data="{\"name\":\"$_sub_domain\",\"value\":\"$txtvalue\",\"ttl\":120}"
+ if ! _rltx_rest DELETE "domains/$_domain_id/dns/acme-txt" "$data"; then
+ _err "Remove TXT record request failed"
+ return 1
+ fi
+ if _contains "$response" '"status":"removed"'; then
+ _info "Removed TXT record, OK"
+ return 0
+ fi
+ _err "Remove TXT record failed: $response"
+ return 1
+}
+
+#################### Private functions below ##################################
+
+_rltx_init() {
+ RLTX_Key="${RLTX_Key:-$(_readaccountconf_mutable RLTX_Key)}"
+ RLTX_OrganizationID="${RLTX_OrganizationID:-$(_readaccountconf_mutable RLTX_OrganizationID)}"
+
+ if [ -z "$RLTX_Key" ] || [ -z "$RLTX_OrganizationID" ]; then
+ RLTX_Key=""
+ RLTX_OrganizationID=""
+ _err "Please specify RLTX_Key and RLTX_OrganizationID."
+ _err "You can export them and retry: export RLTX_Key=... RLTX_OrganizationID=..."
+ return 1
+ fi
+
+ _saveaccountconf_mutable RLTX_Key "$RLTX_Key"
+ _saveaccountconf_mutable RLTX_OrganizationID "$RLTX_OrganizationID"
+}
+
+_get_root() {
+ domain=$1
+ fqdn_encoded="$(printf "%s" "$domain" | _url_encode)"
+ if ! _rltx_rest GET "domains/dns/acme-zone?fqdn=$fqdn_encoded"; then
+ return 1
+ fi
+ if ! _contains "$response" '"domain_id":"'; then
+ return 1
+ fi
+
+ _domain_id="$(printf "%s" "$response" | _egrep_o '"domain_id":"[^"]*"' | cut -d : -f 2 | tr -d '"' | _head_n 1)"
+ _domain="$(printf "%s" "$response" | _egrep_o '"zone":"[^"]*"' | cut -d : -f 2 | tr -d '"' | _head_n 1)"
+ _sub_domain="$(printf "%s" "$response" | _egrep_o '"record_name":"[^"]*"' | cut -d : -f 2 | tr -d '"' | _head_n 1)"
+
+ if [ -z "$_domain_id" ] || [ -z "$_domain" ] || [ -z "$_sub_domain" ]; then
+ return 1
+ fi
+ return 0
+}
+
+_rltx_rest() {
+ m=$1
+ ep="$2"
+ data="$3"
+ _debug "$ep"
+
+ export _H1="X-API-Key: $RLTX_Key"
+ export _H2="X-Organization-ID: $RLTX_OrganizationID"
+ export _H3="Content-Type: application/json"
+
+ if [ "$m" = "GET" ]; then
+ response="$(_get "https://api.ccp.realtoxmedia.de/api/$ep")"
+ else
+ _debug2 data "$data"
+ response="$(_post "$data" "https://api.ccp.realtoxmedia.de/api/$ep" "" "$m")"
+ fi
+
+ if [ "$?" != "0" ]; then
+ _err "Realtox Media Cloudpanel API request failed: $ep"
+ return 1
+ fi
+ _debug2 response "$response"
+ return 0
+}
diff --git a/dnsapi/dns_selectel.sh b/dnsapi/dns_selectel.sh
index 565f541b..8ba9a4fb 100644
--- a/dnsapi/dns_selectel.sh
+++ b/dnsapi/dns_selectel.sh
@@ -368,7 +368,7 @@ _get_auth_token() {
_data_auth="{\"auth\":{\"identity\":{\"methods\":[\"password\"],\"password\":{\"user\":{\"name\":\"${SL_Login_Name}\",\"domain\":{\"name\":\"${SL_Login_ID}\"},\"password\":\"${SL_Pswd}\"}}},\"scope\":{\"project\":{\"name\":\"${SL_Project_Name}\",\"domain\":{\"name\":\"${SL_Login_ID}\"}}}}}"
export _H1="Content-Type: application/json"
_result=$(_post "$_data_auth" "$auth_uri")
- _token_keystone=$(grep 'x-subject-token' "$HTTP_HEADER" | sed -nE "s/[[:space:]]*x-subject-token:[[:space:]]*([[:print:]]*)(\r*)/\1/p")
+ _token_keystone=$(grep 'x-subject-token' "$HTTP_HEADER" | cut -d ':' -f 2- | tr -d ' \t\r')
_dt_curr=$(date +%s)
SL_Token_V2="${SL_Login_Name}${_sl_sep}${_token_keystone}${_sl_sep}${SL_Login_ID}${_sl_sep}${SL_Project_Name}${_sl_sep}${_dt_curr}"
_saveaccountconf_mutable SL_Token_V2 "$SL_Token_V2"
diff --git a/dnsapi/dns_selfhost.sh b/dnsapi/dns_selfhost.sh
index 4912dfdf..25130146 100644
--- a/dnsapi/dns_selfhost.sh
+++ b/dnsapi/dns_selfhost.sh
@@ -7,6 +7,7 @@ Options:
SELFHOSTDNS_USERNAME Username
SELFHOSTDNS_PASSWORD Password
SELFHOSTDNS_MAP Subdomain name
+ SELFHOSTDNS_UPDATE_URL API url. Optional. Default "https://account.selfhost.de/cgi-bin/api.pl"
Issues: github.com/acmesh-official/acme.sh/issues/4291
Author: Marvin Edeler
'
@@ -18,9 +19,11 @@ dns_selfhost_add() {
_debug fulldomain "$fulldomain"
_debug txtvalue "$txt"
- SELFHOSTDNS_UPDATE_URL="https://selfhost.de/cgi-bin/api.pl"
+ DEFAULT_SELFHOSTDNS_UPDATE_URL="https://account.selfhost.de/cgi-bin/api.pl"
# Get values, but don't save until we successfully validated
+ SELFHOSTDNS_UPDATE_URL="${SELFHOSTDNS_UPDATE_URL:-$(_readaccountconf_mutable SELFHOSTDNS_UPDATE_URL)}"
+ SELFHOSTDNS_UPDATE_URL="${SELFHOSTDNS_UPDATE_URL:-$DEFAULT_SELFHOSTDNS_UPDATE_URL}"
SELFHOSTDNS_USERNAME="${SELFHOSTDNS_USERNAME:-$(_readaccountconf_mutable SELFHOSTDNS_USERNAME)}"
SELFHOSTDNS_PASSWORD="${SELFHOSTDNS_PASSWORD:-$(_readaccountconf_mutable SELFHOSTDNS_PASSWORD)}"
# These values are domain dependent, so read them from there
@@ -39,7 +42,10 @@ dns_selfhost_add() {
# only match full domains (at the beginning of the string or with a leading whitespace),
# e.g. don't match mytest.example.com or sub.test.example.com for test.example.com
# if the domain is defined multiple times only the last occurance will be matched
- mapEntry=$(echo "$SELFHOSTDNS_MAP" | sed -n -E "s/(^|^.*[[:space:]])($fulldomain)(:[[:digit:]]+)([:]?[[:digit:]]*)(.*)/\2\3\4/p")
+ # prepend a space to each line so "start of line" and "after whitespace"
+ # can both be matched as "after a space/tab" (portable BRE, no ERE (^|..))
+ _selfhost_tab="$(printf '\t')"
+ mapEntry=$(echo "$SELFHOSTDNS_MAP" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*:\{0,1\}[0-9]*\).*/\1/p")
_debug2 mapEntry "$mapEntry"
if test -z "$mapEntry"; then
_err "SELFHOSTDNS_MAP must contain the fulldomain incl. prefix and at least one RID"
@@ -51,7 +57,7 @@ dns_selfhost_add() {
rid2=$(echo "$mapEntry" | cut -d: -f3)
# read last used rid domain
- lastUsedRidForDomainEntry=$(echo "$SELFHOSTDNS_MAP_LAST_USED_INTERNAL" | sed -n -E "s/(^|^.*[[:space:]])($fulldomain:[[:digit:]]+)(.*)/\2/p")
+ lastUsedRidForDomainEntry=$(echo "$SELFHOSTDNS_MAP_LAST_USED_INTERNAL" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*\).*/\1/p")
_debug2 lastUsedRidForDomainEntry "$lastUsedRidForDomainEntry"
lastUsedRidForDomain=$(echo "$lastUsedRidForDomainEntry" | cut -d: -f2)
@@ -84,6 +90,11 @@ dns_selfhost_add() {
fi
fi
+ # Save api url if different from default
+ if [ "$DEFAULT_SELFHOSTDNS_UPDATE_URL" != "$SELFHOSTDNS_UPDATE_URL" ]; then
+ _saveaccountconf_mutable SELFHOSTDNS_UPDATE_URL "$SELFHOSTDNS_UPDATE_URL"
+ fi
+
# Now that we know the values are good, save them
_saveaccountconf_mutable SELFHOSTDNS_USERNAME "$SELFHOSTDNS_USERNAME"
_saveaccountconf_mutable SELFHOSTDNS_PASSWORD "$SELFHOSTDNS_PASSWORD"
diff --git a/dnsapi/dns_simply.sh b/dnsapi/dns_simply.sh
index e0ad16e2..74e891ad 100644
--- a/dnsapi/dns_simply.sh
+++ b/dnsapi/dns_simply.sh
@@ -8,11 +8,7 @@ Options:
SIMPLY_ApiKey API Key
'
-#SIMPLY_Api="https://api.simply.com/2/"
-SIMPLY_Api_Default="https://api.simply.com/2"
-
-#This is used for determining success of REST call
-SIMPLY_SUCCESS_CODE='"status":200'
+SIMPLY_Api="https://api.simply.com/2"
######## Public functions #####################
#Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
@@ -72,7 +68,16 @@ dns_simply_rm() {
return 1
fi
- records=$(echo "$response" | tr '{' "\n" | grep 'record_id\|type\|data\|\name' | sed 's/\"record_id/;\"record_id/' | tr "\n" ' ' | tr -d ' ' | tr ';' ' ')
+ case "$_simply_http_code" in
+ 2*) ;;
+ *)
+ _err "Failed to fetch DNS records (HTTP $_simply_http_code)"
+ _err "$response"
+ return 1
+ ;;
+ esac
+
+ records=$(echo "$response" | tr '{' "\n" | grep -E 'record_id|type|data|name' | sed 's/\"record_id/;\"record_id/' | tr "\n" ' ' | tr -d ' ' | tr ';' ' ')
nr_of_deleted_records=0
_info "Fetching txt record"
@@ -95,7 +100,7 @@ dns_simply_rm() {
if [ "$record_id" -gt 0 ]; then
- if ! _simply_delete_record "$_domain" "$_sub_domain" "$record_id"; then
+ if ! _simply_delete_record "$_domain" "$record_id"; then
_err "Record with id $record_id could not be deleted"
return 1
fi
@@ -122,14 +127,9 @@ dns_simply_rm() {
#################### Private functions below ##################################
_simply_load_config() {
- SIMPLY_Api="${SIMPLY_Api:-$(_readaccountconf_mutable SIMPLY_Api)}"
SIMPLY_AccountName="${SIMPLY_AccountName:-$(_readaccountconf_mutable SIMPLY_AccountName)}"
SIMPLY_ApiKey="${SIMPLY_ApiKey:-$(_readaccountconf_mutable SIMPLY_ApiKey)}"
- if [ -z "$SIMPLY_Api" ]; then
- SIMPLY_Api="$SIMPLY_Api_Default"
- fi
-
if [ -z "$SIMPLY_AccountName" ] || [ -z "$SIMPLY_ApiKey" ]; then
SIMPLY_AccountName=""
SIMPLY_ApiKey=""
@@ -144,9 +144,6 @@ _simply_load_config() {
}
_simply_save_config() {
- if [ "$SIMPLY_Api" != "$SIMPLY_Api_Default" ]; then
- _saveaccountconf_mutable SIMPLY_Api "$SIMPLY_Api"
- fi
_saveaccountconf_mutable SIMPLY_AccountName "$SIMPLY_AccountName"
_saveaccountconf_mutable SIMPLY_ApiKey "$SIMPLY_ApiKey"
}
@@ -163,26 +160,39 @@ _simply_get_all_records() {
_get_root() {
domain=$1
+
+ if ! _simply_rest GET "my/products/"; then
+ return 1
+ fi
+
+ case "$_simply_http_code" in
+ 2*) ;;
+ *)
+ _err "Failed to fetch product list (HTTP $_simply_http_code)"
+ _err "$response"
+ return 1
+ ;;
+ esac
+
i=2
p=1
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
if [ -z "$h" ]; then
- #not valid
return 1
fi
- if ! _simply_rest GET "my/products/$h/dns/"; then
- return 1
- fi
+ _domain=$(printf "%s" "$response" | tr '}' '\n' |
+ grep -F -e "\"object\":\"$h\"" -e "\"name\":\"$h\"" -e "\"name_idn\":\"$h\"" |
+ sed -n 's/.*"object":"\([^"]*\)".*/\1/p' |
+ _head_n 1)
- if ! _contains "$response" "$SIMPLY_SUCCESS_CODE"; then
- _debug "$h not found"
- else
+ if [ -n "$_domain" ]; then
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
- _domain="$h"
return 0
fi
+
+ _debug "No Simply.com product found for $h"
p="$i"
i=$(_math "$i" + 1)
done
@@ -194,39 +204,44 @@ _simply_add_record() {
sub_domain=$2
txtval=$3
- data="{\"name\": \"$sub_domain\", \"type\":\"TXT\", \"data\": \"$txtval\", \"priority\":0, \"ttl\": 3600}"
+ data="{\"name\": \"$sub_domain\", \"type\":\"TXT\", \"data\": \"$txtval\", \"priority\":0, \"ttl\": 120}"
if ! _simply_rest POST "my/products/$domain/dns/records/" "$data"; then
- _err "Adding record not successfull!"
+ _err "Adding record not successful!"
return 1
fi
- if ! _contains "$response" "$SIMPLY_SUCCESS_CODE"; then
- _err "Call to API not sucessfull, see below message for more details"
+ case "$_simply_http_code" in
+ 2*) ;;
+ *)
+ _err "Call to API not successful (HTTP $_simply_http_code), see below message for more details"
_err "$response"
return 1
- fi
+ ;;
+ esac
return 0
}
_simply_delete_record() {
domain=$1
- sub_domain=$2
- record_id=$3
+ record_id=$2
_debug record_id "Delete record with id $record_id"
if ! _simply_rest DELETE "my/products/$domain/dns/records/$record_id/"; then
- _err "Deleting record not successfull!"
+ _err "Deleting record not successful!"
return 1
fi
- if ! _contains "$response" "$SIMPLY_SUCCESS_CODE"; then
- _err "Call to API not sucessfull, see below message for more details"
+ case "$_simply_http_code" in
+ 2*) ;;
+ *)
+ _err "Call to API not successful (HTTP $_simply_http_code), see below message for more details"
_err "$response"
return 1
- fi
+ ;;
+ esac
return 0
}
@@ -248,17 +263,24 @@ _simply_rest() {
export _H2="Content-Type: application/json"
+ : >"$HTTP_HEADER"
+
if [ "$m" != "GET" ]; then
response="$(_post "$data" "$SIMPLY_Api/$ep" "" "$m")"
else
response="$(_get "$SIMPLY_Api/$ep")"
fi
- if [ "$?" != "0" ]; then
+ _ret="$?"
+ unset _H1 _H2
+
+ if [ "$_ret" != "0" ]; then
_err "error $ep"
return 1
fi
+ _simply_http_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d' ' -f2 | tr -d '\r\n')"
+
response="$(echo "$response" | _normalizeJson)"
_debug2 response "$response"
diff --git a/dnsapi/dns_udr.sh b/dnsapi/dns_udr.sh
index 656a0557..dbc959d6 100644
--- a/dnsapi/dns_udr.sh
+++ b/dnsapi/dns_udr.sh
@@ -145,8 +145,8 @@ _udr_rest() {
_debug data "${data}"
response="$(_post "${data}" "${UDR_API}?s_login=${UDR_USER}&s_pw=${UDR_PASS}" "" "POST")"
- _code=$(echo "$response" | _egrep_o "code = ([0-9]+)" | _head_n 1 | cut -d = -f 2 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
- _description=$(echo "$response" | _egrep_o "description = .*" | _head_n 1 | cut -d = -f 2 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
+ _code=$(echo "$response" | _egrep_o "code = ([0-9]+)" | _head_n 1 | cut -d = -f 2 | tr -d ' \t\r')
+ _description=$(echo "$response" | _egrep_o "description = .*" | _head_n 1 | cut -d = -f 2 | tr -d '\r' | sed -e 's/^[ ]*//' -e 's/[ ]*$//')
_debug response_code "$_code"
_debug response_description "$_description"
diff --git a/dnsapi/dns_volcengine.sh b/dnsapi/dns_volcengine.sh
new file mode 100755
index 00000000..2cc805d5
--- /dev/null
+++ b/dnsapi/dns_volcengine.sh
@@ -0,0 +1,297 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_volcengine_info='Volcano Engine DNS API
+Site: https://www.volcengine.com/docs/6758/155086
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_volcengine
+Options:
+ Volcengine_ACCESS_KEY_ID API Key ID
+ Volcengine_SECRET_ACCESS_KEY API Secret
+ Volcengine_SESSION_TOKEN Session Token. Optional, only needed when using temporary STS credentials.
+Issues: github.com/acmesh-official/acme.sh/issues/7064
+'
+
+Volcengine_HOST="dns.volcengineapi.com"
+Volcengine_URL="https://$Volcengine_HOST"
+
+######## Public functions #####################
+
+#fulldomain txtvalue
+dns_volcengine_add() {
+ fulldomain=$1
+ txtvalue=$2
+ _record_id=""
+
+ Volcengine_ACCESS_KEY_ID="${Volcengine_ACCESS_KEY_ID:-$(_readaccountconf_mutable Volcengine_ACCESS_KEY_ID)}"
+ Volcengine_SECRET_ACCESS_KEY="${Volcengine_SECRET_ACCESS_KEY:-$(_readaccountconf_mutable Volcengine_SECRET_ACCESS_KEY)}"
+
+ if [ -z "$Volcengine_ACCESS_KEY_ID" ] || [ -z "$Volcengine_SECRET_ACCESS_KEY" ]; then
+ Volcengine_ACCESS_KEY_ID=""
+ Volcengine_SECRET_ACCESS_KEY=""
+ _err "You haven't specified the volcengine dns api key id and api key secret yet."
+ return 1
+ fi
+
+ #save the api key and email to the account conf file.
+ _saveaccountconf_mutable Volcengine_ACCESS_KEY_ID "$Volcengine_ACCESS_KEY_ID"
+ _saveaccountconf_mutable Volcengine_SECRET_ACCESS_KEY "$Volcengine_SECRET_ACCESS_KEY"
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain"
+ _sleep 1
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ # _info "Getting existing records for $fulldomain"
+ if ! volcengine_rest POST "" "Action=ListRecords&Version=2018-08-01" "{\"ZID\":$_domain_id,\"Host\":\"$_sub_domain\",\"Type\":\"TXT\",\"Value\":\"$txtvalue\",\"SearchMode\":\"exact\"}"; then
+ _sleep 1
+ return 1
+ fi
+
+ # ListRecords already filtered by ZID + Host + Value + SearchMode:exact,
+ # so any returned record is our target. Don't match on FQDN: Volcengine
+ # lowercases the Host/FQDN in the response, which would break a
+ # case-sensitive string compare against $fulldomain.
+ _record_id="$(echo "$response" | _egrep_o "\"RecordID\":\"[0-9]+\"," | cut -d: -f2 | cut -d, -f1 | tr -d '"')"
+ _debug "_record_id" "$_record_id"
+
+ if [ "$_record_id" ] && _contains "$response" "$txtvalue"; then
+ _info "The TXT record already exists. Skipping."
+ _sleep 1
+ return 0
+ fi
+
+ _debug "Adding records"
+
+ if volcengine_rest POST "" "Action=CreateRecord&Version=2018-08-01" "{\"ZID\":$_domain_id,\"Host\":\"$_sub_domain\",\"Type\":\"TXT\",\"Value\":\"$txtvalue\",\"Remark\":\"acme.sh\"}"; then
+ _info "TXT record updated successfully."
+ _sleep 1
+ return 0
+ fi
+
+ _sleep 1
+ return 1
+}
+
+#fulldomain txtvalue
+dns_volcengine_rm() {
+ fulldomain=$1
+ txtvalue=$2
+ _record_id=""
+
+ Volcengine_ACCESS_KEY_ID="${Volcengine_ACCESS_KEY_ID:-$(_readaccountconf_mutable Volcengine_ACCESS_KEY_ID)}"
+ Volcengine_SECRET_ACCESS_KEY="${Volcengine_SECRET_ACCESS_KEY:-$(_readaccountconf_mutable Volcengine_SECRET_ACCESS_KEY)}"
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain"
+ _sleep 1
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ _info "Getting existing records for $fulldomain"
+
+ if ! volcengine_rest POST "" "Action=ListRecords&Version=2018-08-01" "{\"ZID\":$_domain_id,\"Host\":\"$_sub_domain\",\"Type\":\"TXT\",\"Value\":\"$txtvalue\",\"SearchMode\":\"exact\"}"; then
+ _sleep 1
+ return 1
+ fi
+
+ # ListRecords already filtered by ZID + Host + Value + SearchMode:exact,
+ # so any returned record is our target. Don't match on FQDN: Volcengine
+ # lowercases the Host/FQDN in the response, which would break a
+ # case-sensitive string compare against $fulldomain.
+ _record_id="$(echo "$response" | _egrep_o "\"RecordID\":\"[0-9]+\"," | cut -d: -f2 | cut -d, -f1 | tr -d '"')"
+ _debug "_record_id" "$_record_id"
+
+ if [ -z "$_record_id" ]; then
+ _debug "no records exist, skip"
+ _sleep 1
+ return 0
+ fi
+
+ if volcengine_rest POST "" "Action=DeleteRecord&Version=2018-08-01" "{\"RecordID\":\"$_record_id\"}"; then
+ _info "TXT record deleted successfully."
+ _sleep 1
+ return 0
+ fi
+ _sleep 1
+ return 1
+}
+
+#################### Private functions below ##################################
+
+_get_root() {
+ domain=$1
+ i=1
+ p=1
+
+ # iterate over names (a.b.c.d -> b.c.d -> c.d -> d)
+ while true; do
+ h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+ _debug "Checking domain: $h"
+ if [ -z "$h" ]; then
+ _err "invalid domain"
+ return 1
+ fi
+
+ # iterate over paginated result for list_hosted_zones
+ if ! volcengine_rest POST "" "Action=ListZones&Version=2018-08-01" "{\"Key\":\"$h\",\"SearchMode\":\"exact\"}"; then
+ return 1
+ fi
+ if _contains "$response" "\"ZoneName\":\"$h\""; then
+ _domain_id=$(printf "%s" "$response" | _egrep_o "\"ZID\":[0-9]+," | cut -d: -f2 | cut -d, -f1)
+ if [ "$_domain_id" ]; then
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ _domain=$h
+ return 0
+ fi
+ _err "Can't find domain with id: $h"
+ return 1
+ fi
+ p=$i
+ i=$(_math "$i" + 1)
+ done
+ return 1
+}
+
+#method uri qstr data
+volcengine_rest() {
+ mtd="$1"
+ ep="$2"
+ qsr="$3"
+ data="$4"
+
+ _debug mtd "$mtd"
+ _debug ep "$ep"
+ _debug qsr "$qsr"
+ _debug data "$data"
+
+ # clear any header state left over from a previous request so that
+ # conditionally-set headers (e.g. x-content-sha256, x-security-token)
+ # can't leak into the next request
+ _H1=""
+ _H2=""
+ _H3=""
+ _H4=""
+ _H5=""
+
+ CanonicalURI="/$ep"
+ _debug2 CanonicalURI "$CanonicalURI"
+
+ CanonicalQueryString="$qsr"
+ _debug2 CanonicalQueryString "$CanonicalQueryString"
+
+ RequestDate="$(date -u +"%Y%m%dT%H%M%SZ")"
+ _debug2 RequestDate "$RequestDate"
+
+ Hash="sha256"
+
+ _H1="X-Date: $RequestDate"
+ _debug2 _H1 "$_H1"
+
+ volcengine_host="$Volcengine_HOST"
+ CanonicalHeaders="host:$volcengine_host\n"
+ SignedHeaders="host"
+
+ if [ -n "$data" ]; then
+ XContentSha256="$(printf "%s" "$data" | _digest "$Hash" hex)"
+ _H4="x-content-sha256: $XContentSha256"
+ _debug2 _H4 "$_H4"
+
+ CanonicalHeaders="${CanonicalHeaders}x-content-sha256:$XContentSha256\n"
+ SignedHeaders="${SignedHeaders};x-content-sha256"
+ fi
+
+ CanonicalHeaders="${CanonicalHeaders}x-date:$RequestDate\n"
+ SignedHeaders="${SignedHeaders};x-date"
+
+ if [ -n "$Volcengine_SESSION_TOKEN" ]; then
+ _H3="x-security-token: $Volcengine_SESSION_TOKEN"
+ CanonicalHeaders="${CanonicalHeaders}x-security-token:$Volcengine_SESSION_TOKEN\n"
+ SignedHeaders="${SignedHeaders};x-security-token"
+ fi
+
+ _debug2 CanonicalHeaders "$CanonicalHeaders"
+ _debug2 SignedHeaders "$SignedHeaders"
+
+ RequestPayload="$data"
+ _debug2 RequestPayload "$RequestPayload"
+
+ CanonicalRequest="$mtd\n$CanonicalURI\n$CanonicalQueryString\n$CanonicalHeaders\n$SignedHeaders\n$(printf "%s" "$RequestPayload" | _digest "$Hash" hex)"
+ _debug2 CanonicalRequest "$CanonicalRequest"
+
+ HashedCanonicalRequest="$(printf '%b' "$CanonicalRequest" | _digest "$Hash" hex)"
+ _debug2 HashedCanonicalRequest "$HashedCanonicalRequest"
+
+ Algorithm="HMAC-SHA256"
+ _debug2 Algorithm "$Algorithm"
+
+ RequestDateOnly="$(echo "$RequestDate" | cut -c 1-8)"
+ _debug2 RequestDateOnly "$RequestDateOnly"
+
+ Region="cn-beijing"
+ Service="dns"
+
+ CredentialScope="$RequestDateOnly/$Region/$Service/request"
+ _debug2 CredentialScope "$CredentialScope"
+
+ StringToSign="$Algorithm\n$RequestDate\n$CredentialScope\n$HashedCanonicalRequest"
+
+ _debug2 StringToSign "$StringToSign"
+
+ kSecret="$Volcengine_SECRET_ACCESS_KEY"
+
+ _secure_debug2 kSecret "$kSecret"
+
+ kSecretH="$(printf "%s" "$kSecret" | _hex_dump | tr -d " ")"
+ _secure_debug2 kSecretH "$kSecretH"
+
+ kDateH="$(printf "%s" "$RequestDateOnly" | _hmac "$Hash" "$kSecretH" hex)"
+ _debug2 kDateH "$kDateH"
+
+ kRegionH="$(printf "%s" "$Region" | _hmac "$Hash" "$kDateH" hex)"
+ _debug2 kRegionH "$kRegionH"
+
+ kServiceH="$(printf "%s" "$Service" | _hmac "$Hash" "$kRegionH" hex)"
+ _debug2 kServiceH "$kServiceH"
+
+ kSigningH="$(printf "%s" "request" | _hmac "$Hash" "$kServiceH" hex)"
+ _debug2 kSigningH "$kSigningH"
+
+ signature="$(printf '%b' "$StringToSign" | _hmac "$Hash" "$kSigningH" hex)"
+ _debug2 signature "$signature"
+
+ Authorization="$Algorithm Credential=$Volcengine_ACCESS_KEY_ID/$CredentialScope, SignedHeaders=$SignedHeaders, Signature=$signature"
+ _debug2 Authorization "$Authorization"
+
+ _H2="Authorization: $Authorization"
+ _debug2 _H2 "$_H2"
+
+ url="$Volcengine_URL/$ep"
+ if [ "$qsr" ]; then
+ url="$Volcengine_URL/$ep?$qsr"
+ fi
+
+ if [ "$mtd" = "GET" ]; then
+ response="$(_get "$url")"
+ else
+ response="$(_post "$data" "$url" "" "POST" "application/json")"
+ fi
+
+ _ret="$?"
+ _debug response "$response"
+ if [ "$_ret" = "0" ]; then
+ if _contains "$response" "\"Error\":{"; then
+ _err "Response error:$response"
+ return 1
+ fi
+ fi
+
+ return "$_ret"
+}
diff --git a/dnsapi/dns_wedos.sh b/dnsapi/dns_wedos.sh
new file mode 100644
index 00000000..d1f353e2
--- /dev/null
+++ b/dnsapi/dns_wedos.sh
@@ -0,0 +1,217 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_wedos_info='WEDOS.com
+Site: wedos.com
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_wedos
+Options:
+ WEDOS_Username WAPI login (account email)
+ WEDOS_Wapipass WAPI password
+Issues: github.com/acmesh-official/acme.sh/issues/7071
+Author: Jan Forman
+'
+
+WEDOS_Api="https://api.wedos.com/wapi/json"
+
+######## Public functions #####################
+
+#Usage: dns_wedos_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_wedos_add() {
+ fulldomain=$(echo "$1" | _lower_case)
+ txtvalue=$2
+
+ if ! _wedos_init; then
+ return 1
+ fi
+
+ _debug "Detecting root zone for $fulldomain"
+ if ! _get_root "$fulldomain"; then
+ _err "Cannot determine root zone for: $fulldomain"
+ return 1
+ fi
+ _debug _domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+
+ _info "Adding TXT record: $_sub_domain.$_domain"
+ if ! _wedos_request "dns-row-add" "{\"domain\":\"$_domain\",\"name\":\"$_sub_domain\",\"ttl\":\"300\",\"type\":\"TXT\",\"rdata\":\"$txtvalue\"}"; then
+ _err "Failed to add TXT record"
+ return 1
+ fi
+
+ _info "Committing DNS changes for $_domain"
+ if ! _wedos_request "dns-domain-commit" "{\"name\":\"$_domain\"}"; then
+ _err "Failed to commit DNS changes"
+ return 1
+ fi
+
+ return 0
+}
+
+#Usage: dns_wedos_rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_wedos_rm() {
+ fulldomain=$(echo "$1" | _lower_case)
+ txtvalue=$2
+
+ if ! _wedos_init; then
+ return 1
+ fi
+
+ _debug "Detecting root zone for $fulldomain"
+ if ! _get_root "$fulldomain"; then
+ _err "Cannot determine root zone for: $fulldomain"
+ return 1
+ fi
+ _debug _domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+
+ # _get_root leaves the dns-rows-list response for $_domain in $response
+ _debug "Looking up row IDs for TXT value: $txtvalue"
+ _row_ids=$(echo "$response" | tr '{' '\n' | grep -F -- "\"rdata\":\"$txtvalue\"" | grep -F -- "\"name\":\"$_sub_domain\"" | _egrep_o '"ID": *"[0-9]*"' | tr -dc '0-9\n')
+ _debug _row_ids "$_row_ids"
+
+ if [ -z "$_row_ids" ]; then
+ _info "TXT record not found, nothing to remove"
+ return 0
+ fi
+
+ for _row_id in $_row_ids; do
+ _info "Removing TXT record ID $_row_id from $_domain"
+ if ! _wedos_request "dns-row-delete" "{\"domain\":\"$_domain\",\"row_id\":\"$_row_id\"}"; then
+ _err "Failed to delete TXT record"
+ return 1
+ fi
+ done
+
+ _info "Committing DNS changes for $_domain"
+ if ! _wedos_request "dns-domain-commit" "{\"name\":\"$_domain\"}"; then
+ _err "Failed to commit DNS changes"
+ return 1
+ fi
+
+ return 0
+}
+
+#################### Private functions below ##################################
+
+_wedos_init() {
+ WEDOS_Username="${WEDOS_Username:-$(_readaccountconf_mutable WEDOS_Username)}"
+ WEDOS_Wapipass="${WEDOS_Wapipass:-$(_readaccountconf_mutable WEDOS_Wapipass)}"
+
+ if [ -z "$WEDOS_Username" ] || [ -z "$WEDOS_Wapipass" ]; then
+ WEDOS_Username=""
+ WEDOS_Wapipass=""
+ _err "You didn't specify the WEDOS WAPI credentials yet."
+ _err "Please export WEDOS_Username and WEDOS_Wapipass and try again."
+ return 1
+ fi
+
+ _saveaccountconf_mutable WEDOS_Username "$WEDOS_Username"
+ _saveaccountconf_mutable WEDOS_Wapipass "$WEDOS_Wapipass"
+ return 0
+}
+
+# WAPI auth token: sha1(login + sha1(password) + hour), where the hour is
+# the current hour on the WEDOS servers (Europe/Prague timezone).
+# The POSIX TZ string is used so no tzdata is required on the client.
+_wedos_auth() {
+ if [ "$_wedos_utc" ]; then
+ # fallback: WAPI accepts 1 hour of skew, UTC+1 fits both CET and CEST
+ _wedos_hour=$(date -u +%H)
+ _wedos_hour=$(printf '%02d' "$(((${_wedos_hour#0} + 1) % 24))")
+ else
+ _wedos_hour=$(TZ='CET-1CEST,M3.5.0,M10.5.0/3' date +%H)
+ fi
+ _wedos_phash=$(printf '%s' "$WEDOS_Wapipass" | _digest sha1 hex)
+ printf '%s' "${WEDOS_Username}${_wedos_phash}${_wedos_hour}" | _digest sha1 hex
+}
+
+#Usage: _wedos_request
+#Returns 0 and sets $response on WAPI code 1000, returns 1 otherwise.
+_wedos_request() {
+ _wedos_cmd="$1"
+ _wedos_data="$2"
+
+ _wedos_token=$(_wedos_auth)
+ _secure_debug _wedos_token "$_wedos_token"
+
+ _wedos_json="{\"request\":{\"user\":\"$WEDOS_Username\",\"auth\":\"$_wedos_token\",\"command\":\"$_wedos_cmd\",\"data\":$_wedos_data}}"
+ _debug2 "WAPI command: $_wedos_cmd"
+ _debug2 "WAPI data: $_wedos_data"
+
+ # _post sends the global _H1.._H5 headers with every request; clear them so
+ # headers from earlier API calls are not leaked to the WAPI endpoint.
+ export _H1=""
+ export _H2=""
+ export _H3=""
+ export _H4=""
+ export _H5=""
+
+ _wedos_body="request=$(printf '%s' "$_wedos_json" | _url_encode)"
+ response=$(_post "$_wedos_body" "$WEDOS_Api" "" "POST" "application/x-www-form-urlencoded")
+ if [ "$?" != "0" ]; then
+ _err "WAPI request failed for command '$_wedos_cmd'"
+ return 1
+ fi
+ _debug2 "WAPI response: $response"
+
+ _wedos_code=$(echo "$response" | _egrep_o '"code": *[0-9]*' | _head_n 1 | tr -dc '0-9')
+ _debug2 "WAPI result code: $_wedos_code"
+ if [ "$_wedos_code" = "1000" ]; then
+ return 0
+ fi
+
+ # some systems ignore the TZ variable (Haiku), sending a wrong auth hour;
+ # retry once with the UTC fallback in _wedos_auth
+ if [ "$_wedos_code" = "2050" ] && [ -z "$_wedos_utc" ]; then
+ _wedos_utc=1
+ _wedos_request "$_wedos_cmd" "$_wedos_data"
+ return $?
+ fi
+
+ # 2050 = bad credentials, 2051 = IP not whitelisted, 2052 = IP blocked
+ if [ "$_wedos_code" = "2050" ] || [ "$_wedos_code" = "2051" ] || [ "$_wedos_code" = "2052" ]; then
+ _wedos_result=$(echo "$response" | _egrep_o '"result": *"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
+ _err "WAPI authentication error $_wedos_code: $_wedos_result"
+ _err "Check WEDOS_Username, WEDOS_Wapipass and the WAPI IP whitelist."
+ _wedos_autherr=1
+ return 1
+ fi
+
+ _debug "WAPI error for command '$_wedos_cmd': $response"
+ return 1
+}
+
+# Determine the registered domain (_domain) and subdomain prefix (_sub_domain)
+# by walking up the labels and calling dns-rows-list until WAPI accepts one.
+# _acme-challenge.www.example.co.uk
+# -> _sub_domain=_acme-challenge.www _domain=example.co.uk
+# The full domain itself is tried first, so a zone apex (e.g. DNS alias mode
+# pointing at the registered domain) resolves to an empty _sub_domain.
+_get_root() {
+ _gr_full="$1"
+ _gr_i=1
+ _wedos_autherr=""
+ while true; do
+ _gr_candidate=$(printf '%s' "$_gr_full" | cut -d . -f "${_gr_i}"-100)
+ _debug2 "Checking zone candidate: $_gr_candidate"
+ if [ -z "$_gr_candidate" ]; then
+ return 1
+ fi
+
+ if _wedos_request "dns-rows-list" "{\"domain\":\"$_gr_candidate\"}"; then
+ _domain="$_gr_candidate"
+ if [ "$_gr_i" = "1" ]; then
+ _sub_domain=""
+ else
+ _sub_domain=$(printf '%s' "$_gr_full" | cut -d . -f 1-"$((_gr_i - 1))")
+ fi
+ return 0
+ fi
+
+ # auth error hits every candidate, stop the walk
+ if [ "$_wedos_autherr" ]; then
+ return 1
+ fi
+
+ _gr_i=$((_gr_i + 1))
+ done
+}
diff --git a/dnsapi/dns_world4you.sh b/dnsapi/dns_world4you.sh
index f59715ac..0a1cda6b 100644
--- a/dnsapi/dns_world4you.sh
+++ b/dnsapi/dns_world4you.sh
@@ -61,7 +61,7 @@ dns_world4you_add() {
if _contains "$res" "successfully"; then
return 0
else
- msg=$(echo "$res" | grep -A 20 'alert-notification' | grep 'class="weak-title">[^<]' | sed 's/<[^>]*>//g;s/^\s*//g')
+ msg=$(_w4y_alert_msg "$res")
if [ "$msg" = '' ]; then
_err "Unable to add record: Unknown error"
echo "$ret" >'error-01.html'
@@ -125,7 +125,7 @@ dns_world4you_rm() {
if _contains "$res" "successfully"; then
return 0
else
- msg=$(echo "$res" | grep -A 20 'alert-notification' | grep 'class="weak-title">[^<]' | sed 's/<[^>]*>//g;s/^\s*//g')
+ msg=$(_w4y_alert_msg "$res")
if [ "$msg" = '' ]; then
_err "Unable to remove record: Unknown error"
echo "$ret" >'error-01.html'
@@ -145,6 +145,17 @@ dns_world4you_rm() {
################ Private functions ################
+# Usage: _w4y_alert_msg
+# Extracts the error text out of the alert box of a DNS page.
+# "grep -A" is not portable (Solaris /usr/bin/grep: "illegal option -- A"),
+# so select from the alert to EOF and keep the same number of lines.
+# "\s" is a GNU sed extension, use an explicit space/tab bracket instead.
+_w4y_alert_msg() {
+ _w4y_tab=$(printf '\t')
+ echo "$1" | sed -n '/alert-notification/,$p' | _head_n 21 |
+ grep 'class="weak-title">[^<]' | sed "s/<[^>]*>//g;s/^[ $_w4y_tab]*//"
+}
+
# Usage: _login
_login() {
WORLD4YOU_USERNAME="${WORLD4YOU_USERNAME:-$(_readaccountconf_mutable WORLD4YOU_USERNAME)}"
diff --git a/dnsapi/dns_yandex360.sh b/dnsapi/dns_yandex360.sh
index 18d01361..98841d6b 100644
--- a/dnsapi/dns_yandex360.sh
+++ b/dnsapi/dns_yandex360.sh
@@ -149,7 +149,7 @@ _check_variables() {
org_response="$(echo "$org_response" | _normalizeJson)"
YANDEX360_ORG_ID=$(
echo "$org_response" |
- _egrep_o '"id":[[:space:]]*[0-9]+' |
+ _egrep_o '"id":[ ]*[0-9]+' |
cut -d':' -f2
)
_debug 'Automatically retrieved YANDEX360_ORG_ID' "$YANDEX360_ORG_ID"
@@ -216,7 +216,7 @@ _get_token() {
interval=$(
echo "$response" |
- _egrep_o '"interval":[[:space:]]*[0-9]+' |
+ _egrep_o '"interval":[ ]*[0-9]+' |
cut -d':' -f2
)
_debug 'Polling interval' "$interval"
diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh
index 36c49ce4..cacb5977 100644
--- a/dnsapi/dns_yc.sh
+++ b/dnsapi/dns_yc.sh
@@ -22,21 +22,32 @@ dns_yc_add() {
fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name
txtvalue=$2
+ # YC_SA_Key_File_PEM_b64/Path are always persisted to the domain conf below,
+ # so they must be recovered from there first (account conf is only a
+ # fallback for the YC_Folder_ID case, see the SA_ID/SA_Key_ID save below).
+ YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readdomainconf YC_SA_Key_File_PEM_b64)}"
YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}"
+ YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readdomainconf YC_SA_Key_File_Path)}"
YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}"
if [ "$YC_SA_Key_File_PEM_b64" ]; then
echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >private.key
YC_SA_Key_File="private.key"
+ _yc_key_is_temp=1
_savedomainconf YC_SA_Key_File_PEM_b64 "$YC_SA_Key_File_PEM_b64"
else
YC_SA_Key_File="$YC_SA_Key_File_Path"
+ _yc_key_is_temp=""
_savedomainconf YC_SA_Key_File_Path "$YC_SA_Key_File_Path"
fi
+ YC_Zone_ID="${YC_Zone_ID:-$(_readdomainconf YC_Zone_ID)}"
YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}"
+ YC_Folder_ID="${YC_Folder_ID:-$(_readdomainconf YC_Folder_ID)}"
YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}"
+ YC_SA_ID="${YC_SA_ID:-$(_readdomainconf YC_SA_ID)}"
YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}"
+ YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readdomainconf YC_SA_Key_ID)}"
YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}"
if [ "$YC_SA_ID" ] && [ "$YC_SA_Key_ID" ] && [ "$YC_SA_Key_File" ]; then
@@ -65,11 +76,21 @@ dns_yc_add() {
return 1
fi
else
+ # Clear both possible stores -- YC_Zone_ID/YC_Folder_ID/key material are
+ # persisted to the domain conf, while YC_SA_ID/YC_SA_Key_ID may have been
+ # saved account-wide (Folder_ID mode), so a plain _clearaccountconf alone
+ # would leave stale values behind in whichever store wasn't touched.
+ _cleardomainconf YC_Zone_ID
_clearaccountconf YC_Zone_ID
+ _cleardomainconf YC_Folder_ID
_clearaccountconf YC_Folder_ID
- _clearaccountconf YC_SA_ID
- _clearaccountconf YC_SA_Key_ID
+ _cleardomainconf YC_SA_ID
+ _clearaccountconf_mutable YC_SA_ID
+ _cleardomainconf YC_SA_Key_ID
+ _clearaccountconf_mutable YC_SA_Key_ID
+ _cleardomainconf YC_SA_Key_File_PEM_b64
_clearaccountconf YC_SA_Key_File_PEM_b64
+ _cleardomainconf YC_SA_Key_File_Path
_clearaccountconf YC_SA_Key_File_Path
_err "You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File."
return 1
@@ -110,11 +131,30 @@ dns_yc_rm() {
fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name
txtvalue=$2
+ YC_Zone_ID="${YC_Zone_ID:-$(_readdomainconf YC_Zone_ID)}"
YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}"
+ YC_Folder_ID="${YC_Folder_ID:-$(_readdomainconf YC_Folder_ID)}"
YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}"
+ YC_SA_ID="${YC_SA_ID:-$(_readdomainconf YC_SA_ID)}"
YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}"
+ YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readdomainconf YC_SA_Key_ID)}"
YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}"
+ # See dns_yc_add() for why domain conf is checked before account conf.
+ YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readdomainconf YC_SA_Key_File_PEM_b64)}"
+ YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}"
+ YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readdomainconf YC_SA_Key_File_Path)}"
+ YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}"
+
+ if [ "$YC_SA_Key_File_PEM_b64" ]; then
+ echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >private.key
+ YC_SA_Key_File="private.key"
+ _yc_key_is_temp=1
+ else
+ YC_SA_Key_File="$YC_SA_Key_File_Path"
+ _yc_key_is_temp=""
+ fi
+
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "invalid domain"
@@ -124,16 +164,10 @@ dns_yc_rm() {
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
- _debug "Getting txt records"
- if _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then
- exists_txtvalue=$(echo "$response" | _normalizeJson | _egrep_o "\"data\".*\][^,]*" | _egrep_o "[^:]*$")
- _debug exists_txtvalue "$exists_txtvalue"
- else
- _err "Error: $response"
- return 1
- fi
-
- if _yc_rest POST "zones/$_domain_id:updateRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$exists_txtvalue}]}"; then
+ # upsertRecordSets.deletions removes only the given value from the rrset,
+ # leaving any other values at the same name (e.g. base + wildcard domain)
+ # intact -- no need to read the current data set and recompute it.
+ if _yc_rest POST "zones/$_domain_id:upsertRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":[\"$txtvalue\"]}]}"; then
if _contains "$response" "\"done\": true"; then
_info "Delete, OK"
return 0
@@ -194,7 +228,7 @@ _get_root() {
return 1
fi
if _contains "$response" "\"zone\": \"$h\""; then
- _domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')
+ _domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"')
_debug _domain_id "$_domain_id"
if [ "$_domain_id" ]; then
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
@@ -255,7 +289,9 @@ _yc_login() {
_signature=$(printf "%s.%s" "$header" "$payload" | _sign "$YC_SA_Key_File" "sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1" | _url_replace)
_debug2 _signature "$_signature"
- rm -rf "$YC_SA_Key_File"
+ if [ "$_yc_key_is_temp" ]; then
+ rm -f "$YC_SA_Key_File"
+ fi
_jwt=$(printf "{\"jwt\": \"%s.%s.%s\"}" "$header" "$payload" "$_signature")
_debug2 _jwt "$_jwt"
@@ -264,7 +300,7 @@ _yc_login() {
_iam_response="$(_post "$_jwt" "https://iam.api.cloud.yandex.net/iam/v1/tokens" "" "POST")"
_debug3 _iam_response "$(echo "$_iam_response" | _normalizeJson)"
- YC_Token="$(echo "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')"
+ YC_Token="$(echo "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"')"
_debug3 YC_Token
return 0
diff --git a/dnsapi/dns_zonomi.sh b/dnsapi/dns_zonomi.sh
index ee817381..c783a221 100644
--- a/dnsapi/dns_zonomi.sh
+++ b/dnsapi/dns_zonomi.sh
@@ -5,9 +5,11 @@ Site: zonomi.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_zonomi
Options:
ZM_Key API Key
+OptionsAlt:
+ ZM_Api API endpoint. Default: "https://zonomi.com/app/dns/dyndns.jsp". For RimuHosting use "https://rimuhosting.com/dns/dyndns.jsp".
'
-ZM_Api="https://zonomi.com/app/dns/dyndns.jsp"
+ZM_Api_Default="https://zonomi.com/app/dns/dyndns.jsp"
######## Public functions #####################
@@ -28,6 +30,8 @@ dns_zonomi_add() {
#save the api key to the account conf file.
_saveaccountconf_mutable ZM_Key "$ZM_Key"
+ _zm_init_api
+
_info "Get existing txt records for $fulldomain"
if ! _zm_request "action=QUERY&name=$fulldomain"; then
_err "error"
@@ -64,11 +68,27 @@ dns_zonomi_rm() {
return 1
fi
+ _zm_init_api
+
_zm_request "action=DELETE&type=TXT&name=$fulldomain"
}
#################### Private functions below ##################################
+
+# resolve the API endpoint: zonomi by default, overridable for providers
+# sharing the same API on another host (e.g. RimuHosting)
+_zm_init_api() {
+ ZM_Api="${ZM_Api:-$(_readaccountconf_mutable ZM_Api)}"
+ if [ -z "$ZM_Api" ]; then
+ ZM_Api="$ZM_Api_Default"
+ fi
+ _debug2 ZM_Api "$ZM_Api"
+ if [ "$ZM_Api" != "$ZM_Api_Default" ]; then
+ _saveaccountconf_mutable ZM_Api "$ZM_Api"
+ fi
+}
+
#qstr
_zm_request() {
qstr="$1"
diff --git a/notify/aws_ses.sh b/notify/aws_ses.sh
index 07e0c48c..735e6204 100644
--- a/notify/aws_ses.sh
+++ b/notify/aws_ses.sh
@@ -83,7 +83,43 @@ aws_ses_send() {
response="$(aws_rest POST "" "" "$_data")"
}
+_use_container_role() {
+ # automatically set if running inside ECS
+ if [ -z "$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" ]; then
+ _debug "No ECS environment variable detected"
+ return 1
+ fi
+ _use_metadata "169.254.170.2$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"
+}
+
+_use_instance_role() {
+ _instance_role_name_url="http://169.254.169.254/latest/meta-data/iam/security-credentials/"
+
+ if _get "$_instance_role_name_url" true 1 | _head_n 1 | grep -Fq 401; then
+ _debug "Using IMDSv2"
+ _token_url="http://169.254.169.254/latest/api/token"
+ export _H1="X-aws-ec2-metadata-token-ttl-seconds: 21600"
+ _token="$(_post "" "$_token_url" "" "PUT")"
+ _secure_debug3 "_token" "$_token"
+ if [ -z "$_token" ]; then
+ _debug "Unable to fetch IMDSv2 token from instance metadata"
+ return 1
+ fi
+ export _H1="X-aws-ec2-metadata-token: $_token"
+ fi
+
+ if ! _get "$_instance_role_name_url" true 1 | _head_n 1 | grep -Fq 200; then
+ _debug "Unable to fetch IAM role from instance metadata"
+ return 1
+ fi
+
+ _instance_role_name=$(_get "$_instance_role_name_url" "" 1)
+ _debug "_instance_role_name" "$_instance_role_name"
+ _use_metadata "$_instance_role_name_url$_instance_role_name" "$_token"
+}
+
_use_metadata() {
+ export _H1="X-aws-ec2-metadata-token: $2"
_aws_creds="$(
_get "$1" "" 1 |
_normalizeJson |
diff --git a/notify/customscript.sh b/notify/customscript.sh
new file mode 100644
index 00000000..ba8b07cb
--- /dev/null
+++ b/notify/customscript.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env sh
+
+# Support calling a custom script for notifications
+#
+# export CUSTOMSCRIPT_PATH="/usr/local/bin/acme-notification.sh"
+#
+# The script is called with three arguments:
+# $1 subject
+# $2 content
+# $3 status code (0: success, 1: error, 2: skipped)
+
+customscript_send() {
+ _subject="$1"
+ _content="$2"
+ _statusCode="$3" #0: success, 1: error 2($RENEW_SKIP): skipped
+ _debug "_subject" "$_subject"
+ _debug "_content" "$_content"
+ _debug "_statusCode" "$_statusCode"
+
+ CUSTOMSCRIPT_PATH="${CUSTOMSCRIPT_PATH:-$(_readaccountconf_mutable CUSTOMSCRIPT_PATH)}"
+ if [ -z "$CUSTOMSCRIPT_PATH" ]; then
+ _err "You didn't specify the custom script path CUSTOMSCRIPT_PATH yet."
+ return 1
+ fi
+ if ! _exists "$CUSTOMSCRIPT_PATH"; then
+ _err "The custom script $CUSTOMSCRIPT_PATH does not exist or is not executable."
+ return 1
+ fi
+ _saveaccountconf_mutable CUSTOMSCRIPT_PATH "$CUSTOMSCRIPT_PATH"
+
+ # Invoke directly, never through eval: the subject and content contain
+ # domain names and CA messages, eval would allow command injection.
+ _customscript_result="$("$CUSTOMSCRIPT_PATH" "$_subject" "$_content" "$_statusCode" 2>&1)"
+ _customscript_rc="$?"
+ _debug2 "_customscript_result" "$_customscript_result"
+
+ if [ "$_customscript_rc" != "0" ]; then
+ _err "custom script execution error ($_customscript_rc): $_customscript_result"
+ return 1
+ fi
+
+ _info "custom script executed successfully."
+ return 0
+}
diff --git a/notify/smtp.sh b/notify/smtp.sh
index f5ebebca..a7318692 100644
--- a/notify/smtp.sh
+++ b/notify/smtp.sh
@@ -200,6 +200,7 @@ _smtp_send_curl() {
set -- "$@" \
--upload-file - \
+ --crlf \
--mail-from "$SMTP_FROM" \
--max-time "$SMTP_TIMEOUT"
diff --git a/notify/waha.sh b/notify/waha.sh
new file mode 100755
index 00000000..573295e9
--- /dev/null
+++ b/notify/waha.sh
@@ -0,0 +1,74 @@
+#!/usr/bin/env sh
+
+#Support WAHA (WhatsApp HTTP API) - free, self-hosted WhatsApp API
+#https://waha.devlike.pro/
+
+#Required:
+#WAHA_URL="http://localhost:3000"
+#WAHA_CHAT_ID="1234567890@c.us"
+
+#Optional:
+#WAHA_API_KEY=""
+#WAHA_SESSION="default"
+
+waha_send() {
+ _subject="$1"
+ _content="$2"
+ _statusCode="$3" #0: success, 1: error 2($RENEW_SKIP): skipped
+ _debug "_subject" "$_subject"
+ _debug "_content" "$_content"
+ _debug "_statusCode" "$_statusCode"
+
+ WAHA_URL="${WAHA_URL:-$(_readaccountconf_mutable WAHA_URL)}"
+ if [ -z "$WAHA_URL" ]; then
+ WAHA_URL=""
+ _err "You didn't specify the WAHA server url WAHA_URL yet."
+ _err "Example: export WAHA_URL=\"http://localhost:3000\""
+ return 1
+ fi
+ _saveaccountconf_mutable WAHA_URL "$WAHA_URL"
+
+ WAHA_CHAT_ID="${WAHA_CHAT_ID:-$(_readaccountconf_mutable WAHA_CHAT_ID)}"
+ if [ -z "$WAHA_CHAT_ID" ]; then
+ WAHA_CHAT_ID=""
+ _err "You didn't specify the WhatsApp chat id WAHA_CHAT_ID yet."
+ _err "Example: export WAHA_CHAT_ID=\"1234567890@c.us\""
+ return 1
+ fi
+ _saveaccountconf_mutable WAHA_CHAT_ID "$WAHA_CHAT_ID"
+
+ WAHA_API_KEY="${WAHA_API_KEY:-$(_readaccountconf_mutable WAHA_API_KEY)}"
+ if [ "$WAHA_API_KEY" ]; then
+ _saveaccountconf_mutable WAHA_API_KEY "$WAHA_API_KEY"
+ fi
+
+ WAHA_SESSION="${WAHA_SESSION:-$(_readaccountconf_mutable WAHA_SESSION)}"
+ if [ -z "$WAHA_SESSION" ]; then
+ WAHA_SESSION="default"
+ else
+ _saveaccountconf_mutable WAHA_SESSION "$WAHA_SESSION"
+ fi
+
+ _content=$(printf "*%s*\n%s" "$_subject" "$_content" | _json_encode)
+
+ _data="{\"chatId\": \"$WAHA_CHAT_ID\", "
+ _data="$_data\"text\": \"$_content\", "
+ _data="$_data\"session\": \"$WAHA_SESSION\"}"
+
+ _debug "_data" "$_data"
+
+ if [ "$WAHA_API_KEY" ]; then
+ export _H1="X-Api-Key: $WAHA_API_KEY"
+ fi
+
+ _waha_url="${WAHA_URL}/api/sendText"
+ response="$(_post "$_data" "$_waha_url" "" "POST" "application/json")"
+
+ if [ "$?" = "0" ] && _contains "$response" "\"id\""; then
+ _info "waha send success."
+ return 0
+ fi
+ _err "waha send error."
+ _err "$response"
+ return 1
+}