From 10e7e458af9618004b9fd9f37560706b9e4d3061 Mon Sep 17 00:00:00 2001 From: firestormisp <53045434+firestormisp@users.noreply.github.com> Date: Fri, 1 May 2026 10:43:51 +0200 Subject: [PATCH 001/224] Add dns_firestorm.sh plugin for Firestorm.ch DNS API (#6829) Firestorm.ch is a Swiss hosting provider with managed DNS (PowerDNS). This plugin allows customers to automate Let's Encrypt DNS-01 challenges via the Firestorm DNS API. --- dnsapi/dns_firestorm.sh | 110 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 dnsapi/dns_firestorm.sh 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" +} From 8e71268d039a06325d63cf6571598e6007f6b48d Mon Sep 17 00:00:00 2001 From: Alexey Pakhomov Date: Fri, 1 May 2026 13:58:19 +0500 Subject: [PATCH 002/224] Fix write error $_CRONTAB -l in crontab file (#6920) --- acme.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index e780f61d..71e40d24 100755 --- a/acme.sh +++ b/acme.sh @@ -6274,13 +6274,13 @@ installcronjob() { return 1 fi _info "Installing cron job" - if ! $_CRONTAB -l | grep "$PROJECT_ENTRY --cron"; then + if ! $_CRONTAB -l 2>/dev/null | 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 | { + $_CRONTAB -l 2>/dev/null | { cat echo "$random_minute $random_hour * * * $lesh --cron --home \"$LE_WORKING_DIR\" $_c_entry> /dev/null" } | $_CRONTAB_STDIN From 96f87844cd518329f295334e97746ca27179fcb3 Mon Sep 17 00:00:00 2001 From: nsantorelli Date: Fri, 1 May 2026 11:10:15 +0200 Subject: [PATCH 003/224] Add EuroDNS DNS API plugin (dns_eurodns) (#6903) Co-authored-by: Nicolas Santorelli --- dnsapi/dns_eurodns.sh | 267 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 dnsapi/dns_eurodns.sh 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 +} From cbb8e9068c2ee602c436bfd67e99e4c032f576fa Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 1 May 2026 12:56:00 +0200 Subject: [PATCH 004/224] support dns-persist-01 --- README.md | 68 +++++++++++++++++++----- acme.sh | 155 +++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 197 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 188af0e1..b2f22f3f 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,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 | Persistent DNS TXT record ([draft-ietf-acme-dns-persist-01](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/)) | --- @@ -396,7 +397,50 @@ acme.sh --renew -d example.com --- -### 🔟 Issue Certificates of Different Key Types (ECC or RSA) +### 🔟 Use 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"] +``` + +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. | + +You should get an output like: + +```sh +TXT domain: _validation-persist.example.com +TXT 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 domain` / `TXT 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 +471,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,7 +483,7 @@ 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. @@ -457,7 +501,7 @@ acme.sh --renew -d example.com --force --ecc --- -### 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 +515,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 +539,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 | |---------|------| @@ -555,7 +599,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 +609,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 +621,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 71e40d24..dbed1359 100755 --- a/acme.sh +++ b/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" @@ -4028,6 +4030,85 @@ deactivateaccount() { fi } +#domain wildcard ca_name +#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" + + if [ -z "$_mdpv_domain" ]; then + _err "Please specify a domain with -d." + return 1 + 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_name="_validation-persist.$_mdpv_domain" + + _txt_suffix="; accounturi=$_accUri" + if [ "$_mdpv_wildcard" = "1" ]; then + _txt_suffix="$_txt_suffix; policy=wildcard" + fi + + if [ -n "$_mdpv_ca_name" ]; then + _info "" + _info "Add the following DNS TXT record to enable persistent DNS validation:" + _info "" + _info "$(printf 'TXT domain: %s' "$(__green "$_txt_name")")" + _info "$(printf 'TXT 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 domain: %s' "$(__green "$_txt_name")")" + _info "$(printf 'TXT value : %s' "$(__green "\"$_id$_txt_suffix\"")")" + done + _info "" +} + # domain folder file _findHook() { _hookdomain="$1" @@ -4806,7 +4887,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 @@ -4864,18 +4947,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 +4956,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" @@ -7158,6 +7248,8 @@ Commands: --update-account Update account info. --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,6 +7297,10 @@ 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. @@ -7215,6 +7311,14 @@ Parameters: --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). + --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. + These parameters are to install the cert to nginx/Apache or any other server after issue/renew a cert: @@ -7585,6 +7689,8 @@ _process() { _valid_to="" _certificate_profile="" _extended_key_usage="" + _dns_persist_wildcard="" + _dns_persist_ca_name="" while [ ${#} -gt 0 ]; do case "${1}" in @@ -7679,6 +7785,16 @@ _process() { --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 + ;; --set-notify) _CMD="setnotify" ;; @@ -7822,6 +7938,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" @@ -8238,6 +8362,9 @@ _process() { deactivateaccount) deactivateaccount ;; + makednspersistvalue) + makednspersistvalue "$_domain" "$_dns_persist_wildcard" "$_dns_persist_ca_name" + ;; list) list "$_listraw" "$_domain" ;; From 8d5a5a0e0d96e0510d3e4e274204e8da1ea8b52b Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 1 May 2026 14:43:53 +0200 Subject: [PATCH 005/224] support ARI by default --- acme.sh | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 95 insertions(+), 11 deletions(-) diff --git a/acme.sh b/acme.sh index dbed1359..9e37a406 100755 --- a/acme.sh +++ b/acme.sh @@ -4072,8 +4072,8 @@ makednspersistvalue() { _info "" _info "Add the following DNS TXT record to enable persistent DNS validation:" _info "" - _info "$(printf 'TXT domain: %s' "$(__green "$_txt_name")")" - _info "$(printf 'TXT value: %s' "$(__green "\"$_mdpv_ca_name$_txt_suffix\"")")" + _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 @@ -4103,8 +4103,8 @@ makednspersistvalue() { for _id in $_caaids; do [ -z "$_id" ] && continue _info "" - _info "$(printf 'TXT domain: %s' "$(__green "$_txt_name")")" - _info "$(printf 'TXT value : %s' "$(__green "\"$_id$_txt_suffix\"")")" + _info "$(printf 'TXT persist domain:%s' "$(__green "$_txt_name")")" + _info "$(printf 'TXT persist value :%s' "$(__green "\"$_id$_txt_suffix\"")")" done _info "" } @@ -4790,13 +4790,41 @@ 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. + _replaces_certID="" + if [ "$_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" "'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." @@ -5581,6 +5609,30 @@ $_authorizations_map" Le_NextRenewTime=$(_math "$Le_NextRenewTime" - 86400) 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. + if [ "$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 + _savedomainconf "Le_NextRenewTimeStr" "$Le_NextRenewTimeStr" _savedomainconf "Le_NextRenewTime" "$Le_NextRenewTime" @@ -5676,7 +5728,31 @@ renew() { _debug2 "initpath again." _initpath "$Le_Domain" "$_isEcc" - if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then + # 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. + _ari_should_renew="" + if [ -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" ]; then + _ari_start_t="$(_date2time "$(echo "$_ari_start" | sed 's/\.[0-9]*//')")" + _debug "_ari_start_t" "$_ari_start_t" + if [ "$_ari_start_t" ] && [ "$(_time)" -ge "$_ari_start_t" ]; then + _info "ARI suggestedWindow has started ($(__green "$_ari_start")), proceeding with renewal." + _ari_should_renew="1" + else + _info "ARI suggestedWindow starts at: $(__green "$_ari_start")" + fi + fi + fi + fi + + if [ -z "$FORCE" ] && [ -z "$_ari_should_renew" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then _info "Skipping. Next renewal time is: $(__green "$Le_NextRenewTimeStr")" _info "Add '$(__red '--force')' to force renewal." if [ -z "$_ACME_IN_RENEWALL" ]; then @@ -6698,21 +6774,29 @@ _getSerial() { } #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)" + _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 From 0d772313500e7091598d07ecba02033e0e8432c2 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 1 May 2026 15:10:27 +0200 Subject: [PATCH 006/224] add --dns-persist-days --- README.md | 43 ++++++++++++++++++++++++++++++++++++++----- acme.sh | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b2f22f3f..b87eeedd 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,7 @@ DNS persist mode lets you place a **single, long‑lived `_validation-persist` T #### 🪄 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"] +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: @@ -416,17 +416,18 @@ Options: | `--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 domain: _validation-persist.example.com -TXT value: "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/123456789" +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 domain` / `TXT value` pair as a TXT record at your DNS provider, then wait for it to propagate. +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 @@ -485,7 +486,7 @@ acme.sh --issue -d example.com -d '*.example.com' --dns dns_cf ### 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: @@ -499,6 +500,38 @@ acme.sh --renew -d example.com --force acme.sh --renew -d example.com --force --ecc ``` +#### 📡 ACME Renewal Information (ARI) — RFC 9773 + +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️⃣4️⃣ How to Stop Certificate Renewal diff --git a/acme.sh b/acme.sh index 9e37a406..1dabd2a3 100755 --- a/acme.sh +++ b/acme.sh @@ -4030,19 +4030,33 @@ deactivateaccount() { fi } -#domain wildcard ca_name +#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 + 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)" @@ -4067,6 +4081,11 @@ makednspersistvalue() { 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 "" @@ -7402,6 +7421,10 @@ Parameters: (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: @@ -7775,6 +7798,7 @@ _process() { _extended_key_usage="" _dns_persist_wildcard="" _dns_persist_ca_name="" + _dns_persist_days="" while [ ${#} -gt 0 ]; do case "${1}" in @@ -7879,6 +7903,10 @@ _process() { _dns_persist_ca_name="$2" shift ;; + --dns-persist-days | --dnspersistdays) + _dns_persist_days="$2" + shift + ;; --set-notify) _CMD="setnotify" ;; @@ -8447,7 +8475,7 @@ _process() { deactivateaccount ;; makednspersistvalue) - makednspersistvalue "$_domain" "$_dns_persist_wildcard" "$_dns_persist_ca_name" + makednspersistvalue "$_domain" "$_dns_persist_wildcard" "$_dns_persist_ca_name" "$_dns_persist_days" ;; list) list "$_listraw" "$_domain" From fe5490e0eca7cf1b4cc1079a5239db8e9a09ff7b Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 1 May 2026 15:55:09 +0200 Subject: [PATCH 007/224] fix OpenIndiana.yml --- .github/workflows/DNS.yml | 2 +- .github/workflows/OpenIndiana.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index 00d180b9..c727ba1b 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -634,7 +634,7 @@ jobs: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: nfs - prepare: pkg install socat + prepare: pkg update || true; pkg install socat run: | if [ "${{ secrets.TokenName1}}" ] ; then export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" diff --git a/.github/workflows/OpenIndiana.yml b/.github/workflows/OpenIndiana.yml index b5061ba7..99733b44 100644 --- a/.github/workflows/OpenIndiana.yml +++ b/.github/workflows/OpenIndiana.yml @@ -67,7 +67,7 @@ jobs: envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" - prepare: pkg install socat curl + prepare: pkg update || true; pkg install socat curl sync: nfs run: | cd ../acmetest \ From 3230d00c3d6aa6239000f3f00a3d2af4004b8cd1 Mon Sep 17 00:00:00 2001 From: Alexander Sulfrian Date: Fri, 1 May 2026 16:02:46 +0200 Subject: [PATCH 008/224] Allow renew time relative to the expiration date (#4457) * Allow renew time relative to the expiration date --- acme.sh | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index 1dabd2a3..0f97bcea 100755 --- a/acme.sh +++ b/acme.sh @@ -1015,6 +1015,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() { @@ -1846,6 +1864,25 @@ _date2time() { 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" } @@ -5564,7 +5601,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" @@ -5623,6 +5660,20 @@ $_authorizations_map" Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") fi 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) @@ -7446,6 +7497,7 @@ Parameters: -m, --email Specifies the account email, only valid for the '--install' and '--update-account' command. --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. + 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. From 355b121c79e643d8b382f273cfb262acb475b355 Mon Sep 17 00:00:00 2001 From: Curd Becker <12437061+curdbecker@users.noreply.github.com> Date: Fri, 1 May 2026 17:15:57 +0200 Subject: [PATCH 009/224] Add deployment plugin for Windows RDP via OpenSSH (#6925) * Add deployment plugin for Windows RDP via OpenSSH --- acme.sh | 19 +++++ deploy/windows_rdp.sh | 158 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 deploy/windows_rdp.sh diff --git a/acme.sh b/acme.sh index 0f97bcea..6c191c92 100755 --- a/acme.sh +++ b/acme.sh @@ -1057,6 +1057,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() { diff --git a/deploy/windows_rdp.sh b/deploy/windows_rdp.sh new file mode 100644 index 00000000..e708e9a7 --- /dev/null +++ b/deploy/windows_rdp.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env sh + +# install a certificate on a Windows host over OpenSSH and bind it to the Remote +# Desktop listener (RDP-Tcp). +# +# One ssh invocation does the whole job: +# * the PFX is built locally, base64'd, and embedded as a string literal +# inside a generated PowerShell script; +# * the script is piped to `powershell.exe -Command -` over ssh. No scp, +# no temp files on the Windows host. +# +# First run: +# export DEPLOY_WIN_RDP_HOST=winserver.example.com +# acme.sh --deploy -d winserver.example.com --deploy-hook windows_rdp +# +# Available variables: +# DEPLOY_WIN_RDP_HOST required SSH host +# DEPLOY_WIN_RDP_USER optional SSH user, must be a local administrator (can also by set via ssh_config) +# DEPLOY_WIN_RDP_PORT optional SSH port, default 22 +# DEPLOY_WIN_RDP_SSH_OPTS optional extra ssh options, e.g. +# "-i /root/.ssh/win_id_ed25519 -o StrictHostKeyChecking=yes" +# DEPLOY_WIN_RDP_LISTENER optional RDP listener name, default RDP-Tcp +# DEPLOY_WIN_RDP_RESTART optional "1" to restart TermService after install. +# Active RDP sessions will drop! + +windows_rdp_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 ! _exists "ssh"; then + _err "ssh is required but was not found in PATH." + return 1 + fi + + # ---- configuration ------------------------------------------------------ + _getdeployconf DEPLOY_WIN_RDP_HOST + _getdeployconf DEPLOY_WIN_RDP_USER + _getdeployconf DEPLOY_WIN_RDP_PORT + _getdeployconf DEPLOY_WIN_RDP_SSH_OPTS + _getdeployconf DEPLOY_WIN_RDP_LISTENER + _getdeployconf DEPLOY_WIN_RDP_RESTART + + if [ -z "$DEPLOY_WIN_RDP_HOST" ]; then + _err "DEPLOY_WIN_RDP_HOST must be set." + return 1 + fi + + _savedeployconf DEPLOY_WIN_RDP_HOST "$DEPLOY_WIN_RDP_HOST" + [ -n "$DEPLOY_WIN_RDP_USER" ] && _savedeployconf DEPLOY_WIN_RDP_USER "$DEPLOY_WIN_RDP_USER" + [ -n "$DEPLOY_WIN_RDP_PORT" ] && _savedeployconf DEPLOY_WIN_RDP_PORT "$DEPLOY_WIN_RDP_PORT" + [ -n "$DEPLOY_WIN_RDP_SSH_OPTS" ] && _savedeployconf DEPLOY_WIN_RDP_SSH_OPTS "$DEPLOY_WIN_RDP_SSH_OPTS" + [ -n "$DEPLOY_WIN_RDP_LISTENER" ] && _savedeployconf DEPLOY_WIN_RDP_LISTENER "$DEPLOY_WIN_RDP_LISTENER" + [ -n "$DEPLOY_WIN_RDP_RESTART" ] && _savedeployconf DEPLOY_WIN_RDP_RESTART "$DEPLOY_WIN_RDP_RESTART" + + _port="${DEPLOY_WIN_RDP_PORT:-22}" + _listener="${DEPLOY_WIN_RDP_LISTENER:-RDP-Tcp}" + if [ -n "$DEPLOY_WIN_RDP_USER" ]; then + _target="$DEPLOY_WIN_RDP_USER@$DEPLOY_WIN_RDP_HOST" + else + _target="$DEPLOY_WIN_RDP_HOST" + fi + _pfx_pass="acme" + + # ---- build thumbprint + PFX locally ------------------------------------ + _thumb="$(_fingerprint "$_ccert" 'sha1')" + if [ -z "$_thumb" ]; then + _err "Failed to compute certificate thumbprint." + return 1 + fi + _debug "Thumbprint: $_thumb" + + _debug "Building PFX at $_pfx_file" + _pfx_file="$(_mktemp)" + if ! _toPkcs "$_pfx_file" "$_ckey" "$_ccert" "$_cca" "$_pfx_pass"; then + _err "Failed to build PFX archive." + rm -f "$_pfx_file" + return 1 + fi + _pfx_b64=$(_base64 "multiline" <"$_pfx_file") + rm -f "$_pfx_file" + + # ---- build installer script -------------------------------------------- + if [ "$DEPLOY_WIN_RDP_RESTART" = "1" ]; then + _restart_ps='Restart-Service -Name TermService -Force' + else + _restart_ps='# New RdP connections will pick up the new cert automatically.' + fi + + # Escape every literal `$` with `\$` so the shell does not expand it. + # Values substituted from shell: $_pfx_b64, $_pfx_pass, $_thumb, $_listener. + _ps1=$( + cat < Date: Sat, 2 May 2026 10:50:12 +0200 Subject: [PATCH 010/224] revert --- .github/workflows/DNS.yml | 2 +- .github/workflows/OpenIndiana.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index c727ba1b..00d180b9 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -634,7 +634,7 @@ jobs: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: nfs - prepare: pkg update || true; pkg install socat + prepare: pkg install socat run: | if [ "${{ secrets.TokenName1}}" ] ; then export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" diff --git a/.github/workflows/OpenIndiana.yml b/.github/workflows/OpenIndiana.yml index 99733b44..b5061ba7 100644 --- a/.github/workflows/OpenIndiana.yml +++ b/.github/workflows/OpenIndiana.yml @@ -67,7 +67,7 @@ jobs: envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" - prepare: pkg update || true; pkg install socat curl + prepare: pkg install socat curl sync: nfs run: | cd ../acmetest \ From 798531968736ed1211f553f32a49c7c7f0e3cef0 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 2 May 2026 11:22:48 +0200 Subject: [PATCH 011/224] add wiki --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b87eeedd..ba5d3591 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,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 | Persistent DNS TXT record ([draft-ietf-acme-dns-persist-01](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/)) | +| 📌 [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/)) | --- @@ -399,6 +399,8 @@ acme.sh --renew -d example.com ### 🔟 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. @@ -502,6 +504,8 @@ 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 | From 7b19070d98ae6022fe1e202d96b5f7041c4fd8cb Mon Sep 17 00:00:00 2001 From: neil Date: Tue, 5 May 2026 20:26:37 +0200 Subject: [PATCH 012/224] fix ari https://github.com/acmesh-official/acme.sh/issues/6942#issuecomment-4381535765 --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index 6c191c92..7e00c76a 100755 --- a/acme.sh +++ b/acme.sh @@ -6853,7 +6853,7 @@ deactivate() { #cert _getAKI() { _cert="$1" - openssl x509 -in "$_cert" -text -noout | grep "X509v3 Authority Key Identifier" -A 1 | _tail_n 1 | tr -d ' :' + openssl x509 -in "$_cert" -text -noout | grep "X509v3 Authority Key Identifier" -A 1 | _tail_n 1 | _egrep_o "[A-F0-9:]+" | tr -d ':' } #cert From 36667ab6568207b31c4828c0c0b23ea0e2efd469 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 6 May 2026 20:03:42 +0200 Subject: [PATCH 013/224] fix ari https://github.com/acmesh-official/acme.sh/issues/6942#issuecomment-4382355708 --- acme.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index 7e00c76a..1ff1d9a9 100755 --- a/acme.sh +++ b/acme.sh @@ -6853,13 +6853,13 @@ deactivate() { #cert _getAKI() { _cert="$1" - openssl x509 -in "$_cert" -text -noout | grep "X509v3 Authority Key Identifier" -A 1 | _tail_n 1 | _egrep_o "[A-F0-9:]+" | tr -d ':' + ${ACME_OPENSSL_BIN:-openssl} x509 -in "$_cert" -text -noout | grep "X509v3 Authority Key Identifier" -A 1 | _tail_n 1 | tr -d ': ' | sed "s/keyid//" } #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 From ac75c54ade31363a84bb5b226e34912eb287c833 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 6 May 2026 20:21:21 +0200 Subject: [PATCH 014/224] support NO_ARI=1 https://github.com/acmesh-official/acme.sh/discussions/6938 --- acme.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index 1ff1d9a9..4d12446a 100755 --- a/acme.sh +++ b/acme.sh @@ -4870,8 +4870,12 @@ issue() { # 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 [ "$_ACME_IS_RENEW" = "1" ] && [ "$ACME_RENEWAL_INFO" ] && [ -f "$CERT_PATH" ]; then + 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 @@ -5703,7 +5707,11 @@ $_authorizations_map" # 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. - if [ "$ACME_RENEWAL_INFO" ] && [ -f "$CERT_PATH" ] && [ -z "$_notAfter" ]; then + # 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/.*"//')" @@ -5819,8 +5827,12 @@ renew() { # 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. _ari_should_renew="" - if [ -z "$FORCE" ] && [ -f "$CERT_PATH" ]; then + 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" From 47378b563001a975b3bfd6380c1a482ad0b28729 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 6 May 2026 20:23:52 +0200 Subject: [PATCH 015/224] start 3.1.4 --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index 4d12446a..bee84471 100755 --- a/acme.sh +++ b/acme.sh @@ -1,6 +1,6 @@ #!/usr/bin/env sh -VER=3.1.3 +VER=3.1.4 PROJECT_NAME="acme.sh" From eaf4b62ba94bcb51edeabf84d2f055d2b9c59dde Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 6 May 2026 20:47:25 +0200 Subject: [PATCH 016/224] fix for ari --- acme.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/acme.sh b/acme.sh index bee84471..df11e6f3 100755 --- a/acme.sh +++ b/acme.sh @@ -6884,6 +6884,16 @@ _getARICertID() { _debug2 "_aki" "$_aki" _debug2 "_ser" "$_ser" + # 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 | _url_replace)" From 1687cbd5b483b916559ddf4636ecbafee1656b28 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 6 May 2026 20:56:52 +0200 Subject: [PATCH 017/224] fix format --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index df11e6f3..ece30867 100755 --- a/acme.sh +++ b/acme.sh @@ -6891,7 +6891,7 @@ _getARICertID() { # 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" ;; + [89aAbBcCdDeEfF]*) _ser="00$_ser" ;; esac _akiurl="$(echo "$_aki" | _h2b | _base64 | _url_replace)" From 85408bfb4dd0414cf2153267252d5aee3227817a Mon Sep 17 00:00:00 2001 From: hebbet Date: Thu, 14 May 2026 13:41:25 +0200 Subject: [PATCH 018/224] Update renewal condition in acme.sh script --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index ece30867..aa9d34a0 100755 --- a/acme.sh +++ b/acme.sh @@ -4655,7 +4655,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" From 010bd1111a7f44c02f6d8415a1ab915cd145226d Mon Sep 17 00:00:00 2001 From: Tom Sommer Date: Sun, 24 May 2026 22:20:40 +0200 Subject: [PATCH 019/224] Improve Simply.com API (#6933) Improve Simply.com API (#6933) --- dnsapi/dns_simply.sh | 92 +++++++++++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 35 deletions(-) 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" From ce07759cede1fddc70d303200137bc157e04e2fd Mon Sep 17 00:00:00 2001 From: Markus Ebner Date: Sun, 24 May 2026 22:24:00 +0200 Subject: [PATCH 020/224] [dnsapi] add IP-Projects dns hook (#6959) --- dnsapi/dns_ipprojects.sh | 91 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 dnsapi/dns_ipprojects.sh 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 +} From d9ce7fefa1f3106b39101b7864980f90d2616d9a Mon Sep 17 00:00:00 2001 From: "Simon V." <218359733+sim0n-v@users.noreply.github.com> Date: Sun, 24 May 2026 22:25:52 +0200 Subject: [PATCH 021/224] ARI - Add support for switching ACME Server during renewal (#6983) * ARI - Add support for switching ACME Server during renewal https://github.com/acmesh-official/acme.sh/issues/6964 * Restore old condition while adding malformed --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index aa9d34a0..f67749c0 100755 --- a/acme.sh +++ b/acme.sh @@ -4895,7 +4895,7 @@ issue() { # (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" "'replaces'" || _contains "$response" "ARI"; }; then + 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." From 206f4494ac5977c359e33382c23575deab5c8cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20N=C3=A6ss?= <102598494+InvisibleDuck@users.noreply.github.com> Date: Sun, 24 May 2026 22:34:56 +0200 Subject: [PATCH 022/224] Add Poweradmin DNS API plugin (dns_poweradmin) (#6943) * Add Poweradmin DNS API plugin (dns_poweradmin) --- dnsapi/dns_poweradmin.sh | 238 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 dnsapi/dns_poweradmin.sh diff --git a/dnsapi/dns_poweradmin.sh b/dnsapi/dns_poweradmin.sh new file mode 100644 index 00000000..db31fa4f --- /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"[[:space:]]*:[[:space:]]*false'; then + _err "API reported failure on $method $ep" + _debug "Response: $response" + return 1 + fi + + _debug2 "API Response: $response" + return 0 +} From b7e9214e2d65b1099f6cd0dfc321ffa7b06159b0 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 8 May 2026 20:44:48 +0200 Subject: [PATCH 023/224] minor --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index f67749c0..6ab2d8d8 100755 --- a/acme.sh +++ b/acme.sh @@ -5785,7 +5785,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 From 5713c1d39d3dd89f6b6698aaf26181ea9bb8c382 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 30 May 2026 11:48:06 +0200 Subject: [PATCH 024/224] remove dns_hetzner.sh https://github.com/acmesh-official/acme.sh/issues/6990#issuecomment-4576551997 --- dnsapi/dns_hetzner.sh | 256 ------------------------------------------ 1 file changed, 256 deletions(-) delete mode 100755 dnsapi/dns_hetzner.sh diff --git a/dnsapi/dns_hetzner.sh b/dnsapi/dns_hetzner.sh deleted file mode 100755 index f1bddc61..00000000 --- a/dnsapi/dns_hetzner.sh +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env sh -# shellcheck disable=SC2034 -dns_hetzner_info='Hetzner.com -Site: Hetzner.com -Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_hetzner -Options: - HETZNER_Token API Token -Issues: github.com/acmesh-official/acme.sh/issues/2943 -' - -HETZNER_Api="https://dns.hetzner.com/api/v1" - -######## Public functions ##################### - -# Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" -# Used to add txt record -# Ref: https://dns.hetzner.com/api-docs/ -dns_hetzner_add() { - full_domain=$1 - txt_value=$2 - - HETZNER_Token="${HETZNER_Token:-$(_readaccountconf_mutable HETZNER_Token)}" - - if [ -z "$HETZNER_Token" ]; then - HETZNER_Token="" - _err "You didn't specify a Hetzner api token." - _err "You can get yours from here https://dns.hetzner.com/settings/api-token." - return 1 - fi - - #save the api key and email to the account conf file. - _saveaccountconf_mutable HETZNER_Token "$HETZNER_Token" - - _debug "First detect the root zone" - - if ! _get_root "$full_domain"; then - _err "Invalid domain" - return 1 - fi - _debug _domain_id "$_domain_id" - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - _debug "Getting TXT records" - if ! _find_record "$_sub_domain" "$txt_value"; then - return 1 - fi - - if [ -z "$_record_id" ]; then - _info "Adding record" - if _hetzner_rest POST "records" "{\"zone_id\":\"${HETZNER_Zone_ID}\",\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"value\":\"$txt_value\",\"ttl\":120}"; then - if _contains "$response" "$txt_value"; then - _info "Record added, OK" - _sleep 2 - return 0 - fi - fi - _err "Add txt record error${_response_error}" - return 1 - else - _info "Found record id: $_record_id." - _info "Record found, do nothing." - return 0 - # we could modify a record, if the names for txt records for *.example.com and example.com would be not the same - #if _hetzner_rest PUT "records/${_record_id}" "{\"zone_id\":\"${HETZNER_Zone_ID}\",\"type\":\"TXT\",\"name\":\"$full_domain\",\"value\":\"$txt_value\",\"ttl\":120}"; then - # if _contains "$response" "$txt_value"; then - # _info "Modified, OK" - # return 0 - # fi - #fi - #_err "Add txt record error (modify)." - #return 1 - fi -} - -# Usage: full_domain txt_value -# Used to remove the txt record after validation -dns_hetzner_rm() { - full_domain=$1 - txt_value=$2 - - HETZNER_Token="${HETZNER_Token:-$(_readaccountconf_mutable HETZNER_Token)}" - - _debug "First detect the root zone" - if ! _get_root "$full_domain"; then - _err "Invalid domain" - return 1 - fi - _debug _domain_id "$_domain_id" - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - _debug "Getting TXT records" - if ! _find_record "$_sub_domain" "$txt_value"; then - return 1 - fi - - if [ -z "$_record_id" ]; then - _info "Remove not needed. Record not found." - else - if ! _hetzner_rest DELETE "records/$_record_id"; then - _err "Delete record error${_response_error}" - return 1 - fi - _sleep 2 - _info "Record deleted" - fi -} - -#################### Private functions below ################################## -#returns -# _record_id=a8d58f22d6931bf830eaa0ec6464bf81 if found; or 1 if error -_find_record() { - unset _record_id - _record_name=$1 - _record_value=$2 - - if [ -z "$_record_value" ]; then - _record_value='[^"]*' - fi - - _debug "Getting all records" - _hetzner_rest GET "records?zone_id=${_domain_id}" - - if _response_has_error; then - _err "Error${_response_error}" - return 1 - else - _record_id=$( - echo "$response" | - grep -o "{[^\{\}]*\"name\":\"$_record_name\"[^\}]*}" | - grep "\"value\":\"$_record_value\"" | - while read -r record; do - # test for type and - if [ -n "$(echo "$record" | _egrep_o '"type":"TXT"')" ]; then - echo "$record" | _egrep_o '"id":"[^"]*"' | cut -d : -f 2 | tr -d \" - break - fi - done - ) - fi -} - -#_acme-challenge.www.domain.com -#returns -# _sub_domain=_acme-challenge.www -# _domain=domain.com -# _domain_id=sdjkglgdfewsdfg -_get_root() { - domain=$1 - i=1 - p=1 - - domain_without_acme=$(echo "$domain" | cut -d . -f 2-) - domain_param_name=$(echo "HETZNER_Zone_ID_for_${domain_without_acme}" | sed 's/[\.\-]/_/g') - - _debug "Reading zone_id for '$domain_without_acme' from config..." - HETZNER_Zone_ID=$(_readdomainconf "$domain_param_name") - if [ "$HETZNER_Zone_ID" ]; then - _debug "Found, using: $HETZNER_Zone_ID" - if ! _hetzner_rest GET "zones/${HETZNER_Zone_ID}"; then - _debug "Zone with id '$HETZNER_Zone_ID' does not exist." - _cleardomainconf "$domain_param_name" - unset HETZNER_Zone_ID - else - if _contains "$response" "\"id\":\"$HETZNER_Zone_ID\""; then - _domain=$(printf "%s\n" "$response" | _egrep_o '"name":"[^"]*"' | cut -d : -f 2 | tr -d \" | head -n 1) - if [ "$_domain" ]; then - _cut_length=$((${#domain} - ${#_domain} - 1)) - _sub_domain=$(printf "%s" "$domain" | cut -c "1-$_cut_length") - _domain_id="$HETZNER_Zone_ID" - return 0 - else - return 1 - fi - else - return 1 - fi - fi - fi - - _debug "Trying to get zone id by domain name for '$domain_without_acme'." - while true; do - h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - if [ -z "$h" ]; then - #not valid - return 1 - fi - _debug h "$h" - - _hetzner_rest GET "zones?name=$h" - - if _contains "$response" "\"name\":\"$h\"" || _contains "$response" '"total_entries":1'; then - _domain_id=$(echo "$response" | _egrep_o "\[.\"id\":\"[^\"]*\"" | _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 - HETZNER_Zone_ID=$_domain_id - _savedomainconf "$domain_param_name" "$HETZNER_Zone_ID" - return 0 - fi - return 1 - fi - p=$i - i=$(_math "$i" + 1) - done - return 1 -} - -#returns -# _response_error -_response_has_error() { - unset _response_error - - err_part="$(echo "$response" | _egrep_o '"error":\{[^\}]*\}')" - - if [ -n "$err_part" ]; then - err_code=$(echo "$err_part" | _egrep_o '"code":[0-9]+' | cut -d : -f 2) - err_message=$(echo "$err_part" | _egrep_o '"message":"[^"]+"' | cut -d : -f 2 | tr -d \") - - if [ -n "$err_code" ] && [ -n "$err_message" ]; then - _response_error=" - message: ${err_message}, code: ${err_code}" - return 0 - fi - fi - - return 1 -} - -#returns -# response -_hetzner_rest() { - m=$1 - ep="$2" - data="$3" - _debug "$ep" - - key_trimmed=$(echo "$HETZNER_Token" | tr -d \") - - export _H1="Content-TType: application/json" - export _H2="Auth-API-Token: $key_trimmed" - - if [ "$m" != "GET" ]; then - _debug data "$data" - response="$(_post "$data" "$HETZNER_Api/$ep" "" "$m")" - else - response="$(_get "$HETZNER_Api/$ep")" - fi - - if [ "$?" != "0" ] || _response_has_error; then - _debug "Error$_response_error" - return 1 - fi - _debug2 response "$response" - return 0 -} From dfbe2c5bff139a89b7a782d365870aee8b9eaa50 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 4 Jun 2026 20:15:12 +0100 Subject: [PATCH 025/224] fix _getAKI() on OpenBSD (#7007) The order of the arguments does matter for OpenBSD's grep (bug or feature). --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index 6ab2d8d8..00192868 100755 --- a/acme.sh +++ b/acme.sh @@ -6865,7 +6865,7 @@ deactivate() { #cert _getAKI() { _cert="$1" - ${ACME_OPENSSL_BIN:-openssl} x509 -in "$_cert" -text -noout | grep "X509v3 Authority Key Identifier" -A 1 | _tail_n 1 | tr -d ': ' | sed "s/keyid//" + ${ACME_OPENSSL_BIN:-openssl} x509 -in "$_cert" -text -noout | grep -A 1 "X509v3 Authority Key Identifier" | _tail_n 1 | tr -d ': ' | sed "s/keyid//" } #cert From c7c903fba3188ac2c9063a9fa9b14a2f991bbe25 Mon Sep 17 00:00:00 2001 From: terafin Date: Thu, 4 Jun 2026 12:25:38 -0700 Subject: [PATCH 026/224] ci: add GitHub Container Registry (ghcr.io) publishing (#7005) --- .github/workflows/dockerhub.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml index 0e7ba748..d10c17a8 100644 --- a/.github/workflows/dockerhub.yml +++ b/.github/workflows/dockerhub.yml @@ -41,6 +41,9 @@ jobs: runs-on: ubuntu-latest needs: CheckToken if: "contains(needs.CheckToken.outputs.hasToken, 'true')" + permissions: + contents: read + packages: write steps: - name: checkout code uses: actions/checkout@v6 @@ -58,6 +61,9 @@ jobs: - name: login to docker hub run: | echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + - name: login to ghcr + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - name: build and push the image run: | if [[ $GITHUB_REF == refs/tags/* ]]; then @@ -73,6 +79,8 @@ jobs: fi fi + echo "DOCKER_IMAGE_TAG=${DOCKER_IMAGE_TAG}" >>"$GITHUB_ENV" + DOCKER_LABELS=() while read -r label; do DOCKER_LABELS+=(--label "${label}") @@ -84,3 +92,9 @@ jobs: --output "type=image,push=true" \ --build-arg AUTO_UPGRADE=${AUTO_UPGRADE} \ --platform linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/386,linux/ppc64le,linux/s390x . + - name: mirror the image to ghcr (best-effort) + run: | + docker buildx imagetools create \ + --tag ghcr.io/${{ github.repository }}:${DOCKER_IMAGE_TAG} \ + ${DOCKER_IMAGE}:${DOCKER_IMAGE_TAG} \ + || echo "::warning::GHCR mirror failed; Docker Hub publish unaffected" From 2e4e5d7955530932673e446f4be41e32e2204c6b Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 4 Jun 2026 22:34:24 +0200 Subject: [PATCH 027/224] add tribblix --- .github/workflows/DNS.yml | 58 ++++++++++++++++++++++++- .github/workflows/Tribblix.yml | 79 ++++++++++++++++++++++++++++++++++ README.md | 2 + 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/Tribblix.yml diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index 00d180b9..232c9b0f 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -661,9 +661,65 @@ jobs: - Haiku: + Tribblix: runs-on: ubuntu-latest needs: OpenIndiana + env: + TEST_DNS : ${{ secrets.TEST_DNS }} + TestingDomain: ${{ secrets.TestingDomain }} + TEST_DNS_NO_WILDCARD: ${{ secrets.TEST_DNS_NO_WILDCARD }} + TEST_DNS_NO_SUBDOMAIN: ${{ secrets.TEST_DNS_NO_SUBDOMAIN }} + TEST_DNS_SLEEP: ${{ secrets.TEST_DNS_SLEEP }} + CASE: le_test_dnsapi + TEST_LOCAL: 1 + DEBUG: ${{ secrets.DEBUG }} + http_proxy: ${{ secrets.http_proxy }} + https_proxy: ${{ secrets.https_proxy }} + HTTPS_INSECURE: 1 # always set to 1 to ignore https error, since Tribblix doesn't accept the expired ISRG X1 root + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v6 + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/tribblix-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + sync: nfs + prepare: zap install socat + run: | + if [ "${{ secrets.TokenName1}}" ] ; then + export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" + fi + if [ "${{ secrets.TokenName2}}" ] ; then + export ${{ secrets.TokenName2}}="${{ secrets.TokenValue2}}" + fi + if [ "${{ secrets.TokenName3}}" ] ; then + export ${{ secrets.TokenName3}}="${{ secrets.TokenValue3}}" + fi + if [ "${{ secrets.TokenName4}}" ] ; then + export ${{ secrets.TokenName4}}="${{ secrets.TokenValue4}}" + fi + if [ "${{ secrets.TokenName5}}" ] ; then + export ${{ secrets.TokenName5}}="${{ secrets.TokenValue5}}" + fi + cd ../acmetest + ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" + + + + Haiku: + runs-on: ubuntu-latest + needs: Tribblix env: TEST_DNS : ${{ secrets.TEST_DNS }} TestingDomain: ${{ secrets.TestingDomain }} diff --git a/.github/workflows/Tribblix.yml b/.github/workflows/Tribblix.yml new file mode 100644 index 00000000..cd43e0e3 --- /dev/null +++ b/.github/workflows/Tribblix.yml @@ -0,0 +1,79 @@ +name: Tribblix +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/Tribblix.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Tribblix.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + Tribblix: + strategy: + matrix: + include: + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + ACME_USE_WGET: 1 + #- TEST_ACME_Server: "ZeroSSL.com" + # CA_ECDSA: "ZeroSSL ECC DV SSL CA 2" + # CA: "ZeroSSL RSA DV SSL CA 2" + # CA_EMAIL: "githubtest@acme.sh" + # TEST_PREFERRED_CHAIN: "" + runs-on: ubuntu-latest + env: + TEST_LOCAL: 1 + TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }} + CA_ECDSA: ${{ matrix.CA_ECDSA }} + CA: ${{ matrix.CA }} + CA_EMAIL: ${{ matrix.CA_EMAIL }} + TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }} + ACME_USE_WGET: ${{ matrix.ACME_USE_WGET }} + steps: + - uses: actions/checkout@v6 + - uses: anyvm-org/cf-tunnel@v0 + id: tunnel + with: + protocol: http + port: 8080 + - name: Set envs + run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/tribblix-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' + nat: | + "8080": "80" + prepare: zap install socat curl wget + sync: nfs + run: | + cd ../acmetest \ + && ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/README.md b/README.md index ba5d3591..22700b4c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ MidnightBSD Omnios OpenIndiana + Tribblix Haiku

@@ -112,6 +113,7 @@ |23|-----| OpenWRT: Tested and working. See [wiki page](https://github.com/acmesh-official/acme.sh/wiki/How-to-run-on-OpenWRT) |24|[![](https://acmesh-official.github.io/acmetest/status/proxmox.svg)](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|[![Haiku](https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml)|Haiku OS +|26|[![Tribblix](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml)|Tribblix > 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest) From 0e5f1518aab3937eb690aa421ed48078c79b2ecd Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 4 Jun 2026 22:57:50 +0200 Subject: [PATCH 028/224] upgrade --- .github/workflows/dockerhub.yml | 4 ++-- .github/workflows/issue.yml | 2 +- .github/workflows/pr_dns.yml | 2 +- .github/workflows/pr_notify.yml | 2 +- .github/workflows/wiki-monitor.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml index d10c17a8..7dc42290 100644 --- a/.github/workflows/dockerhub.yml +++ b/.github/workflows/dockerhub.yml @@ -50,14 +50,14 @@ jobs: with: persist-credentials: false - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v4 - name: Extract Docker metadata id: meta uses: docker/metadata-action@v6 with: images: ${DOCKER_IMAGE} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v4 - name: login to docker hub run: | echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index e92b0411..c659fce5 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -7,7 +7,7 @@ jobs: comment: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v9 with: script: | github.rest.issues.createComment({ diff --git a/.github/workflows/pr_dns.yml b/.github/workflows/pr_dns.yml index 558ebf48..19763a15 100644 --- a/.github/workflows/pr_dns.yml +++ b/.github/workflows/pr_dns.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest if: github.actor != 'neilpang' steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v9 with: script: | await github.rest.issues.createComment({ diff --git a/.github/workflows/pr_notify.yml b/.github/workflows/pr_notify.yml index 416ed721..76ae76f6 100644 --- a/.github/workflows/pr_notify.yml +++ b/.github/workflows/pr_notify.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest if: github.actor != 'neilpang' steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v9 with: script: | await github.rest.issues.createComment({ diff --git a/.github/workflows/wiki-monitor.yml b/.github/workflows/wiki-monitor.yml index a706529a..7e5d7ca3 100644 --- a/.github/workflows/wiki-monitor.yml +++ b/.github/workflows/wiki-monitor.yml @@ -51,7 +51,7 @@ jobs: } > wiki-change-msg.txt - name: Create issue to notify Neilpang - uses: peter-evans/create-issue-from-file@v5 + uses: peter-evans/create-issue-from-file@v6 with: title: "Wiki edited" content-filepath: ./wiki-change-msg.txt From b4634719514509d2485aff87c23800fee116ac59 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 5 Jun 2026 19:22:25 +0200 Subject: [PATCH 029/224] fix localaddress https://github.com/acmesh-official/acme.sh/issues/7009#issuecomment-4633681701 --- acme.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/acme.sh b/acme.sh index 00192868..e6272c9f 100755 --- a/acme.sh +++ b/acme.sh @@ -5312,6 +5312,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 From a2f046306e5089b2ab82b247a319218b3aa30278 Mon Sep 17 00:00:00 2001 From: Adrian Fedoreanu Date: Fri, 5 Jun 2026 19:28:08 +0200 Subject: [PATCH 030/224] dns_1984hosting: cleanup, memoize zone id (#6978) * dns_1984hosting: cleanup, memoize zone id, optional OTP --- dnsapi/dns_1984hosting.sh | 51 +++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/dnsapi/dns_1984hosting.sh b/dnsapi/dns_1984hosting.sh index 8d9676ac..8ed9b8ef 100755 --- a/dnsapi/dns_1984hosting.sh +++ b/dnsapi/dns_1984hosting.sh @@ -7,6 +7,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_1984hosting Options: One984HOSTING_Username Username One984HOSTING_Password Password + One984HOSTING_TOTP_Secret Base32 TOTP shared secret. Required only if the account has 2FA enabled. Requires oathtool. Used to mint the OTP code automatically at login so cron renewals keep working. Issues: github.com/acmesh-official/acme.sh/issues/2851 Author: Adrian Fedoreanu ' @@ -124,11 +125,28 @@ _1984hosting_login() { _debug "Login to 1984Hosting as user $One984HOSTING_Username." username=$(printf '%s' "$One984HOSTING_Username" | _url_encode) password=$(printf '%s' "$One984HOSTING_Password" | _url_encode) - url="https://1984.hosting/api/auth/" - _get "https://1984.hosting/accounts/login/" | grep "csrfmiddlewaretoken" - csrftoken="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'csrftoken=[^;]*;' | tr -d ';')" - sessionid="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'cookie1984nammnamm=[^;]*;' | tr -d ';')" + # When 2FA is enabled, mint a fresh TOTP code from the stored shared secret. + # Empty otpkey is accepted by the server when 2FA is off. + otpkey="" + if [ -n "$One984HOSTING_TOTP_Secret" ]; then + if ! _exists oathtool; then + _err "oathtool is required to use One984HOSTING_TOTP_Secret for 2FA. Please install it." + return 1 + fi + otpcode="$(oathtool --base32 --totp "$One984HOSTING_TOTP_Secret" 2>/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" From 58d9c8d7f613c1975f694df417d78dda805d5629 Mon Sep 17 00:00:00 2001 From: rajcz Date: Fri, 5 Jun 2026 19:38:50 +0200 Subject: [PATCH 031/224] acme.sh: validate cert response before writing .cer (#7006) --- acme.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/acme.sh b/acme.sh index e6272c9f..a7397be1 100755 --- a/acme.sh +++ b/acme.sh @@ -5544,6 +5544,13 @@ $_authorizations_map" return 1 fi + 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" >"$CERT_PATH" _split_cert_chain "$CERT_PATH" "$CERT_FULLCHAIN_PATH" "$CA_CERT_PATH" if [ -z "$_preferred_chain" ]; then @@ -5563,6 +5570,11 @@ $_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" From 9b597b3f1bb424b2b782c54b74955bd9efa37c8e Mon Sep 17 00:00:00 2001 From: aitor422 Date: Fri, 5 Jun 2026 20:02:14 +0000 Subject: [PATCH 032/224] Add CDMON Api (#6984) * Added CDMon DNS API --- dnsapi/dns_cdmon.sh | 137 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 dnsapi/dns_cdmon.sh diff --git a/dnsapi/dns_cdmon.sh b/dnsapi/dns_cdmon.sh new file mode 100644 index 00000000..470fb5fe --- /dev/null +++ b/dnsapi/dns_cdmon.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 + +dns_cdmon_info='cdmon +Site: www.cdmon.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_cdmon +Options: + CDMON_Key API Key +' + +CDMON_Api="https://api-domains.cdmon.services/api-domains" + +######## Public functions ##################### +# Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +# Used to add txt record +dns_cdmon_add() { + fulldomain=$1 + txtvalue=$2 + + CDMON_Key="${CDMON_Key:-$(_readaccountconf_mutable CDMON_Key)}" + + if [ -z "$CDMON_Key" ]; then + CDMON_Key="" + _err "You didn't specify your cdmon api key yet." + _err "Please create your key and try again." + return 1 + fi + + _saveaccountconf_mutable CDMON_Key "$CDMON_Key" + + _debug "First, we detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + _info "Adding record" + if _cdmon_rest "dnsrecords/create" "{\"data\":{\"type\":\"TXT\",\"domain\":\"$_domain\",\"value\":\"$txtvalue\",\"ttl\":120,\"host\":\"$_sub_domain\"}}"; then + if _contains "$response" "\"status\":\"ok\""; then + _info "Added, OK" + return 0 + else + _err "Add txt record error." + return 1 + fi + fi + _err "Add txt record error." + return 1 +} + +# Usage: fulldomain txtvalue +# Used to remove the txt record after validation +dns_cdmon_rm() { + fulldomain=$1 + txtvalue=$2 + + CDMON_Key="${CDMON_Key:-$(_readaccountconf_mutable CDMON_Key)}" + _debug "First, we detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _info "Removing record" + if _cdmon_rest "dnsrecords/delete" "{\"data\":{\"value\":\"$txtvalue\",\"type\":\"TXT\",\"domain\":\"$_domain\",\"host\":\"$_sub_domain\"}}"; then + if _contains "$response" "\"status\":\"ok\""; then + _info "Deleted, OK" + return 0 + else + _err "Delete txt record error." + return 1 + fi + fi + _err "Delete txt record error." + return 1 +} + +#################### 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 + + if ! _cdmon_rest "domains/list"; then + return 1 + fi + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug h "$h" + if [ -z "$h" ]; then + #not valid + return 1 + fi + if _contains "$response" "\"domain\":\"$h\""; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + return 0 + fi + p=$i + i=$(_math "$i" + 1) + done + return 1 +} + +_cdmon_rest() { + ep="$1" + data="$2" + _debug "$ep" + + key_trimmed=$(echo "$CDMON_Key" | tr -d '"') + + export _H1="Content-Type: application/json" + export _H2="apikey: $key_trimmed" + + _debug data "$data" + response="$(_post "$data" "$CDMON_Api/$ep")" + _ret="$?" + + unset _H1 _H2 + + if [ "$_ret" != "0" ]; then + _err "error $ep" + return 1 + fi + _debug2 response "$response" + return 0 +} From d98fa53f627ab08e17616a8037cea86e496aa917 Mon Sep 17 00:00:00 2001 From: Bill <80264737+billzee@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:06:49 -0400 Subject: [PATCH 033/224] Updated AWS Route53 service endpoint to the dual-stack endpoint (#6994) * Update to dual-stack service endpoint --- dnsapi/dns_aws.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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" From 4575877d48643494d6e5769c0468386ea6a80a77 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 5 Jun 2026 23:01:31 +0200 Subject: [PATCH 034/224] add GhostBSD --- .github/workflows/DNS.yml | 58 +++++++++++++++++++++++- .github/workflows/GhostBSD.yml | 80 ++++++++++++++++++++++++++++++++++ README.md | 2 + 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/GhostBSD.yml diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index 232c9b0f..06dd29ac 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -260,7 +260,7 @@ jobs: - OpenBSD: + GhostBSD: runs-on: ubuntu-latest needs: FreeBSD env: @@ -281,6 +281,62 @@ jobs: TokenName5: ${{ secrets.TokenName5}} steps: - uses: actions/checkout@v6 + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/ghostbsd-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + prepare: pkg install -y socat curl + usesh: true + sync: nfs + run: | + if [ "${{ secrets.TokenName1}}" ] ; then + export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" + fi + if [ "${{ secrets.TokenName2}}" ] ; then + export ${{ secrets.TokenName2}}="${{ secrets.TokenValue2}}" + fi + if [ "${{ secrets.TokenName3}}" ] ; then + export ${{ secrets.TokenName3}}="${{ secrets.TokenValue3}}" + fi + if [ "${{ secrets.TokenName4}}" ] ; then + export ${{ secrets.TokenName4}}="${{ secrets.TokenValue4}}" + fi + if [ "${{ secrets.TokenName5}}" ] ; then + export ${{ secrets.TokenName5}}="${{ secrets.TokenValue5}}" + fi + cd ../acmetest + ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" + + + + OpenBSD: + runs-on: ubuntu-latest + needs: GhostBSD + env: + TEST_DNS : ${{ secrets.TEST_DNS }} + TestingDomain: ${{ secrets.TestingDomain }} + TEST_DNS_NO_WILDCARD: ${{ secrets.TEST_DNS_NO_WILDCARD }} + TEST_DNS_NO_SUBDOMAIN: ${{ secrets.TEST_DNS_NO_SUBDOMAIN }} + TEST_DNS_SLEEP: ${{ secrets.TEST_DNS_SLEEP }} + CASE: le_test_dnsapi + TEST_LOCAL: 1 + DEBUG: ${{ secrets.DEBUG }} + http_proxy: ${{ secrets.http_proxy }} + https_proxy: ${{ secrets.https_proxy }} + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v6 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/openbsd-vm@v1 diff --git a/.github/workflows/GhostBSD.yml b/.github/workflows/GhostBSD.yml new file mode 100644 index 00000000..2dd2412b --- /dev/null +++ b/.github/workflows/GhostBSD.yml @@ -0,0 +1,80 @@ +name: GhostBSD +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/GhostBSD.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/GhostBSD.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + GhostBSD: + strategy: + matrix: + include: + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + ACME_USE_WGET: 1 + #- TEST_ACME_Server: "ZeroSSL.com" + # CA_ECDSA: "ZeroSSL ECC DV SSL CA 2" + # CA: "ZeroSSL RSA DV SSL CA 2" + # CA_EMAIL: "githubtest@acme.sh" + # TEST_PREFERRED_CHAIN: "" + runs-on: ubuntu-latest + env: + TEST_LOCAL: 1 + TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }} + CA_ECDSA: ${{ matrix.CA_ECDSA }} + CA: ${{ matrix.CA }} + CA_EMAIL: ${{ matrix.CA_EMAIL }} + TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }} + ACME_USE_WGET: ${{ matrix.ACME_USE_WGET }} + steps: + - uses: actions/checkout@v6 + - uses: anyvm-org/cf-tunnel@v0 + id: tunnel + with: + protocol: http + port: 8080 + - name: Set envs + run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/ghostbsd-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' + nat: | + "8080": "80" + prepare: pkg install -y socat curl wget + usesh: true + sync: nfs + run: | + cd ../acmetest \ + && ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/README.md b/README.md index 22700b4c..3a697691 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Solaris DragonFlyBSD MidnightBSD + GhostBSD Omnios OpenIndiana Tribblix @@ -114,6 +115,7 @@ |24|[![](https://acmesh-official.github.io/acmetest/status/proxmox.svg)](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|[![Haiku](https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml)|Haiku OS |26|[![Tribblix](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml)|Tribblix +|27|[![GhostBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml)|GhostBSD > 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest) From db098055de3ea8012190db7e71d02a0e229a0e75 Mon Sep 17 00:00:00 2001 From: SpeedGriffon <5631890+SpeedGriffon@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:23:03 +0200 Subject: [PATCH 035/224] Fix RouterOS deploy (#7034) * routeros: save ROUTER_OS_ADDITIONAL_SERVICES as base64 * routeros: remove cer_3 --- deploy/routeros.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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=\\\"\\\";\ From 365d2d10f3d5e170d6e9b92d2f79b2c8b86bdd75 Mon Sep 17 00:00:00 2001 From: regisvidal-bitmapz Date: Fri, 19 Jun 2026 14:24:20 +0200 Subject: [PATCH 036/224] Fix dns_namesilo_rm failing to remove TXT record (#6969) * Fixes #6907 --- dnsapi/dns_namesilo.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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." From d60c75b4e30a26addd8b9d0bfdeeed39c84ed51e Mon Sep 17 00:00:00 2001 From: ZeroSSL-Andreas Date: Fri, 19 Jun 2026 17:06:57 +0200 Subject: [PATCH 037/224] New Banner Updated README to include responsive images for dark and light modes. --- README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 22700b4c..792ef979 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,21 @@

- - zerossl.com - + + + + + + + + ZeroSSL + +

🔐 acme.sh

From 0dc97187e129d90bb42168f8cca9d9b425ad8c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ko=C5=82odziejczak?= <31549762+mrl5@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:41:15 +0200 Subject: [PATCH 038/224] docs: introduce contributing doc (#7052) prevents friction and frustrations like in issue #7050 closes #7050 --- CONTRIBUTING.md | 8 ++++++++ README.md | 2 ++ 2 files changed, 10 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..33294ce7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# Contributing + +1. Do NOT send pull request to `master` branch. +Please send to `dev` branch instead. +Any PR to `master` branch will NOT be merged. + +2. For dns api support, read this guide first: https://github.com/acmesh-official/acme.sh/wiki/DNS-API-Dev-Guide +You will NOT get any review without passing this guide. You also need to fix the CI errors. diff --git a/README.md b/README.md index 3a697691..44a73e83 100644 --- a/README.md +++ b/README.md @@ -615,6 +615,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)] From b3579ff18dcf3c530293b096e232fab4b4b87216 Mon Sep 17 00:00:00 2001 From: Jeroen Moors Date: Sun, 28 Jun 2026 19:27:41 +0200 Subject: [PATCH 039/224] Implement support for DNS Level27 (#7043) * Add Level27 DNS API support Implements dns_level27_add and dns_level27_rm for the Level27 (level27.eu) DNS API, used for ACME dns-01 challenges. - Authenticates with a persistent API key via the Authorization header. - Resolves the registered zone with domains?filter and exact fullname match (supports DNS alias mode). - Removes the challenge record by its exact TXT value, leaving other records intact (wildcard-safe). - Optional LEVEL27_API override for non-default/staging endpoints. * A little better documentation --------- Co-authored-by: Jeroen Moors --- dnsapi/dns_level27.sh | 197 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 dnsapi/dns_level27.sh 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 +} From 2998106bd1cebebf681bafec23b19113bb6e21ac Mon Sep 17 00:00:00 2001 From: Alexander Stehlik Date: Wed, 1 Jul 2026 12:54:24 +0200 Subject: [PATCH 040/224] fix(dns_desec): fix rate limit and compatibility issues (#7027) * fix(dns_desec): sleep after DNS record change to prevent rate limit issues Also: make sure the subname is lowercase to fix tests where the acmetestXyzRandomName subdomain is used. * fix: make regexes POSIX-compatible (for OpenBSD) * chore: use _sleep instead of sleep to follow acme.sh standards --- dnsapi/dns_desec.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dnsapi/dns_desec.sh b/dnsapi/dns_desec.sh index d6b9c355..275babea 100644 --- a/dnsapi/dns_desec.sh +++ b/dnsapi/dns_desec.sh @@ -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 From c38182897d55e811aa8e09c00f68890afbc8360f Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 1 Jul 2026 18:59:14 +0800 Subject: [PATCH 041/224] fix ghostbsd --- .github/workflows/DNS.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index 06dd29ac..a972ae1a 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -263,6 +263,8 @@ jobs: GhostBSD: runs-on: ubuntu-latest needs: FreeBSD + # GhostBSD VM frequently flakes on boot/ssh; don't let it fail the whole run + continue-on-error: true env: TEST_DNS : ${{ secrets.TEST_DNS }} TestingDomain: ${{ secrets.TestingDomain }} From 0d53d29f7efa6b40c6193c1b9b338da180cd24f7 Mon Sep 17 00:00:00 2001 From: hostup <52465293+hostup@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:44:21 +0200 Subject: [PATCH 042/224] Update dns_hostup.sh to v2 API (#7014) * Update dns_hostup.sh Update to v2 api support; developer.hostup.se * Update dns_hostup.sh --- dnsapi/dns_hostup.sh | 326 ++++++++++++++++++++++++++----------------- 1 file changed, 201 insertions(+), 125 deletions(-) diff --git a/dnsapi/dns_hostup.sh b/dnsapi/dns_hostup.sh index b3211069..73189189 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 < Date: Wed, 1 Jul 2026 14:55:55 +0200 Subject: [PATCH 043/224] Adding custom Port definitions for truenas (#7033) * closing bracket and adding port for customer installations * adding savedeployconfig * fixing shfmt * changeing --------- Co-authored-by: neil --- deploy/truenas_ws.sh | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) 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=" Date: Wed, 1 Jul 2026 15:05:07 +0200 Subject: [PATCH 044/224] fix(dns_infomaniak): correctly detect API errors (#7048) The add/rm success check never rejected anything: for any non-empty API response it always reported "Record added"/"Record deleted" and returned 0, so the _err branch was dead code. A valid key looked fine only because the API call genuinely created the record; an invalid key returning {"result":"error"} produced the same "Record added" output even though nothing was created. Root cause, in: if [ -n "$response" ]; then if [ ! "$(echo "$response" | _contains '"result":"success"')" ]; then - _contains() ignores stdin (it reads only $1 and $2), so the piped "$response" was discarded. - The pattern '"result":"success"' was passed as $1 (the haystack), leaving $2 (the needle) empty, so it ran: echo '"result":"success"' | grep -- "" >/dev/null 2>&1 grep with an empty pattern always matches. - That grep output is redirected to /dev/null, so the command substitution always captured "", making [ ! "" ] always true. Fix: call _contains "$response" '"result":"success"' directly and branch on its exit code, so error responses now correctly fail (return 1). Co-authored-by: neil --- dnsapi/dns_infomaniak.sh | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/dnsapi/dns_infomaniak.sh b/dnsapi/dns_infomaniak.sh index 0ae32b47..52417fef 100755 --- a/dnsapi/dns_infomaniak.sh +++ b/dnsapi/dns_infomaniak.sh @@ -85,12 +85,10 @@ dns_infomaniak_add() { # API call response=$(_post "$data" "${INFOMANIAK_API_URL}/2/zones/${zone}/records") - if [ -n "$response" ]; then - if [ ! "$(echo "$response" | _contains '"result":"success"')" ]; then - _info "Record added" - _debug "response: $response" - return 0 - fi + if _contains "$response" '"result":"success"'; then + _info "Record added" + _debug "response: $response" + return 0 fi _err "Could not create record." _debug "Response: $response" @@ -169,11 +167,10 @@ dns_infomaniak_rm() { # API call response=$(_post "" "${INFOMANIAK_API_URL}/2/zones/${zone}/records/${record_id}" "" DELETE) - if [ -n "$response" ]; then - if [ ! "$(echo "$response" | _contains '"result":"success"')" ]; then - _info "Record deleted" - return 0 - fi + if _contains "$response" '"result":"success"'; then + _info "Record deleted" + _debug "response: $response" + return 0 fi _err "Could not delete record." _debug "Response: $response" From d0fcafe29b157b727a34a8961137022b3bb11950 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 1 Jul 2026 21:22:36 +0800 Subject: [PATCH 045/224] fix https://github.com/acmesh-official/acme.sh/issues/7035 --- acme.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index a7397be1..59700442 100755 --- a/acme.sh +++ b/acme.sh @@ -7512,8 +7512,8 @@ Parameters: --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. From a7ccfcf91d5843840f113a60e65ed11af3d438e8 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 1 Jul 2026 21:33:24 +0800 Subject: [PATCH 046/224] fix --- .github/workflows/GhostBSD.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/GhostBSD.yml b/.github/workflows/GhostBSD.yml index 2dd2412b..c77fdf2e 100644 --- a/.github/workflows/GhostBSD.yml +++ b/.github/workflows/GhostBSD.yml @@ -42,6 +42,8 @@ jobs: # CA_EMAIL: "githubtest@acme.sh" # TEST_PREFERRED_CHAIN: "" runs-on: ubuntu-latest + # GhostBSD VM frequently flakes on boot/ssh; don't let it fail the whole run + continue-on-error: true env: TEST_LOCAL: 1 TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }} From 81100db2f3f4ead0c6e28a48a939aaee7f401a08 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 1 Jul 2026 21:59:41 +0800 Subject: [PATCH 047/224] fix https://github.com/acmesh-official/acme.sh/issues/6498 --- dnsapi/dns_joker.sh | 51 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) 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 From c83eed499473861631d4316f9a6831db1251b5b9 Mon Sep 17 00:00:00 2001 From: bluenenschloss Date: Thu, 2 Jul 2026 07:00:49 +0200 Subject: [PATCH 048/224] dns_inwx: fix IDN zone detection without python dependency (#7056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * dns_inwx: fix IDN zone detection without python dependency INWX returns zone names in Unicode form (e.g. lünenschloß.de) even when the domain was registered as an IDN. When acme.sh passes the SAN in punycode (xn--lnenschlo-o1a42a.de), _contains never matches and _get_root falls through to the TLD, placing the TXT record in the wrong zone. Previous fix used python3 which is not available in all environments (BusyBox, BSD, minimal containers). Replace with _idn()-based approach: extract values from the nameserver.list XML response, encode each via _idn(), and compare to $h. When a match is found, use the original Unicode zone name for createRecord. Fixes #7038 * dns_inwx: fix shebang, use _egrep_o, shfmt cleanup - Revert shebang to #!/usr/bin/env sh (POSIX sh, fixes ShellCheck) - Replace grep -o with _egrep_o for portability - shfmt -i 2: drop backslash continuation after pipe, fix indentation Requested by @neilpang * fix: drop closing from _egrep_o pattern to avoid sed delimiter collision --------- Co-authored-by: bluenenschloss --- dnsapi/dns_inwx.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/dnsapi/dns_inwx.sh b/dnsapi/dns_inwx.sh index 2cf91404..dba23846 100755 --- a/dnsapi/dns_inwx.sh +++ b/dnsapi/dns_inwx.sh @@ -312,6 +312,22 @@ _get_root() { _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 From b039ff3087e84ce664eedb048bb2e539f881f218 Mon Sep 17 00:00:00 2001 From: pxMan79 Date: Thu, 2 Jul 2026 13:02:30 +0800 Subject: [PATCH 049/224] fix(dns_baidu): prefer new Baidu DNS API with legacy BCD fallback (#6992) * fix(acme): prefer new Baidu DNS API with legacy BCD fallback Keep the existing BCD implementation and add fallback support for the newer Baidu DNS record API. Prefer the new API by default, then fall back to the legacy BCD API to reduce compatibility risk. * fix(dns_baidu): route through _get/_post + restore legacy BCD auth headers Per review: _baidu_dns_call now uses _get/_post with _H1.._H5 (no raw curl, no __HTTP_STATUS__ parsing); _baidu_bcd_post restores _H1.._H5 so the legacy BCD path sends the Authorization signature again (fixes 401). --------- Co-authored-by: neil --- dnsapi/dns_baidu.sh | 329 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 278 insertions(+), 51 deletions(-) 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 < Date: Thu, 2 Jul 2026 07:06:41 +0200 Subject: [PATCH 050/224] Implemented support for Aruba Business DNS API (#7042) * Add support for arubabusiness api * Fix formatting * record names are always converted to lowercase * Docs * remove leftover unconditional authentication call lowercase urlencoded body + x-www-form-urlencoded content-type cleanup header variables cleanup typos grammar * Strengthen _ab_rest failure checks Properly process parallel lists in _ab_dns_record_id Remove hard fails when a txt record already exists * fix json parsing * Fix formatting --------- Co-authored-by: Manwe-Sulimo --- dnsapi/dns_arubabusiness.sh | 490 ++++++++++++++++++++++++++++++++++++ 1 file changed, 490 insertions(+) create mode 100644 dnsapi/dns_arubabusiness.sh 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 +} From e52d75d762fc389966915d7952328e390d24424e Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 2 Jul 2026 13:09:48 +0800 Subject: [PATCH 051/224] fix https://github.com/acmesh-official/acme.sh/issues/7062 --- dnsapi/dns_selfhost.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_selfhost.sh b/dnsapi/dns_selfhost.sh index 4912dfdf..40cc0210 100644 --- a/dnsapi/dns_selfhost.sh +++ b/dnsapi/dns_selfhost.sh @@ -18,7 +18,7 @@ dns_selfhost_add() { _debug fulldomain "$fulldomain" _debug txtvalue "$txt" - SELFHOSTDNS_UPDATE_URL="https://selfhost.de/cgi-bin/api.pl" + SELFHOSTDNS_UPDATE_URL="https://account.selfhost.de/cgi-bin/api.pl" # Get values, but don't save until we successfully validated SELFHOSTDNS_USERNAME="${SELFHOSTDNS_USERNAME:-$(_readaccountconf_mutable SELFHOSTDNS_USERNAME)}" From 5433ea86c8fcf10c038f75f93cf3a5f12e4a3afe Mon Sep 17 00:00:00 2001 From: Toni Karppi Date: Thu, 2 Jul 2026 07:10:29 +0200 Subject: [PATCH 052/224] Add Glesys dnsapi provider (#7059) * Add Glesys dnsapi provider * Fix typo in error message for dns_glesys_add * Use API to get record id in Glesys provider * Use listrecords API endpoint to find root domain * Remove record id parsin from add function --- dnsapi/dns_glesys.sh | 263 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 dnsapi/dns_glesys.sh 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 +} From a50fad865fd63cafe83efc3007b02748fdad9568 Mon Sep 17 00:00:00 2001 From: MarFri <163347538+M4rFri@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:08:59 +0200 Subject: [PATCH 053/224] DNS_IONOS double sending content type & case sensitive mismatch (#7028) * double sending content type results in error from ionos * Normalize fulldomain to lowercase in the _ionos_get_record function. --- dnsapi/dns_ionos.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From ccd2f04c338d2a4dffaa55539003014b713bba23 Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 2 Jul 2026 23:15:29 +0800 Subject: [PATCH 054/224] fix https://github.com/acmesh-official/acme.sh/issues/6986 --- acme.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/acme.sh b/acme.sh index 59700442..ad8a3839 100755 --- a/acme.sh +++ b/acme.sh @@ -4707,6 +4707,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 From 42e13fa7970fcd69f54be937a3488d61644c7a33 Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 2 Jul 2026 23:18:26 +0800 Subject: [PATCH 055/224] fix https://github.com/acmesh-official/acme.sh/issues/6851 --- dnsapi/dns_infomaniak.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_infomaniak.sh b/dnsapi/dns_infomaniak.sh index 52417fef..43ade8ce 100755 --- a/dnsapi/dns_infomaniak.sh +++ b/dnsapi/dns_infomaniak.sh @@ -129,7 +129,7 @@ dns_infomaniak_rm() { fi export _H1="Authorization: Bearer $INFOMANIAK_API_TOKEN" - export _H2="ContentType: application/json" + export _H2="Content-Type: application/json" fulldomain=$1 txtvalue=$2 From 116c05fbff85574b0065af7846cebe9c91c181e9 Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 2 Jul 2026 23:30:56 +0800 Subject: [PATCH 056/224] fix https://github.com/acmesh-official/acme.sh/issues/6400 --- acme.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index ad8a3839..d714d421 100755 --- a/acme.sh +++ b/acme.sh @@ -7738,9 +7738,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 From ced8d72808810c69703f52eda1f48accdd9181fa Mon Sep 17 00:00:00 2001 From: "Simon V." <218359733+sim0n-v@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:12:32 +0200 Subject: [PATCH 057/224] ARI - Run cron job more frequently (#6939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ARI - Run cron job more frequently With ACME Renewal Info (RFC9773 §4.3), fetching renewal window should be more frequent, e.g. in case of revocation incident. "For instance, a server that needs to revoke certificates within 24 hours of notification of a problem might choose to reserve twelve hours for investigation, six hours for clients to fetch updated RenewalInfo objects, and six hours for clients to perform a renewal." More flexible option is to run the cron job every hour and only refresh ARI when the last one + Retry-After header is in the past. * Fix cron job schedule for certificate renewal * Fix random_hour syntax in cron job installation * Update Windows task scheduler to run more frequently Add support for randomized hour and update frequency Ref: * [/mo](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/schtasks-create#to-schedule-a-task-to-run-every-n-hours) * [/SC HOURLY](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/schtasks-create#parameters) * Add padding for StartTime (/ST) in SCHTASKS.exe * New Banner Updated README to include responsive images for dark and light modes. * rebase * Reset README --------- Co-authored-by: ZeroSSL-Andreas --- acme.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index d714d421..ad7a6325 100755 --- a/acme.sh +++ b/acme.sh @@ -6470,6 +6470,7 @@ _install_win_taskscheduler() { _lesh="$1" _centry="$2" _randomminute="$3" + _randomhour="$4" if ! _exists cygpath; then _err "cygpath not found" return 1 @@ -6539,7 +6540,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" @@ -6548,7 +6549,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 @@ -6570,7 +6571,7 @@ installcronjob() { fi $_CRONTAB -l 2>/dev/null | { cat - echo "$random_minute $random_hour * * * $lesh --cron --home \"$LE_WORKING_DIR\" $_c_entry> /dev/null" + echo "$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" } | $_CRONTAB_STDIN fi if [ "$?" != "0" ]; then From fdf528c26c72884981dec9600834c8411a87ca32 Mon Sep 17 00:00:00 2001 From: Howtin Date: Fri, 3 Jul 2026 18:09:32 +0800 Subject: [PATCH 058/224] feat: Add Volcano Engine DNS API (#7069) * feat: add volcengine dns api * fix(volcengine): address review findings and fix record matching Code review fixes: - fix format string usage in signature computation (use printf %b / %s) - clear _H1.._H5 header state at start of request to avoid leaking conditionally-set headers into subsequent requests - check ListZones return status in _get_root - document Volcengine_SESSION_TOKEN option and fix duplicate "and" typo - fix Docs and Issues sections - remove and update some code comments Functional fixes: - stop matching ListRecords results by FQDN string: Volcengine lowercases the Host/FQDN in responses, so a case-sensitive compare against $fulldomain failed for mixed-case names, making rm silently skip deletion and add lose idempotency. ListRecords is already filtered by ZID+Host+Value+SearchMode:exact, so just extract RecordID from the result instead. - reset _record_id at the start of add/rm to avoid stale state leaking across calls within the same process - tag created records with Remark "acme.sh" for easier identification - adjust debug levels: hide Authorization header behind _debug2, surface response at _debug Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: wenxuan70 Co-authored-by: Claude Opus 4.8 --- dnsapi/dns_volcengine.sh | 297 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100755 dnsapi/dns_volcengine.sh 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" +} From f03895819262753b8b9699437967daadb4aeb891 Mon Sep 17 00:00:00 2001 From: CZECHIA-COM Date: Fri, 3 Jul 2026 12:12:25 +0200 Subject: [PATCH 059/224] fix(dns_czechia): read _normalizeJson input from stdin, not as an argument (#7077) _normalizeJson reads its JSON from stdin (sed | sed | tr) and ignores any positional argument. dns_czechia_add() called it as `_normalizeJson "$_res"`, so the response was discarded and the inner sed blocked reading from stdin. When issuing for a single domain, or for a record that already exists, the "already exists" branch returns early and never reaches this call, which is why the bug stayed hidden. With multiple domains, the first record often short-circuits on "already exists" while the next, freshly-added record reaches the broken call and hangs on interactive runs (or consumes unrelated stdin non-interactively). Pipe the response into _normalizeJson via stdin, matching dns_czechia_rm() and every other dnsapi plugin. Co-authored-by: Claude Opus 4.8 (1M context) --- dnsapi/dns_czechia.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_czechia.sh b/dnsapi/dns_czechia.sh index f0f4c32e..e2ffcf50 100644 --- a/dnsapi/dns_czechia.sh +++ b/dnsapi/dns_czechia.sh @@ -76,7 +76,7 @@ dns_czechia_add() { return 0 fi - _nres="$(_normalizeJson "$_res")" + _nres="$(printf '%s' "$_res" | _normalizeJson)" if [ "$?" -ne 0 ] || [ -z "$_nres" ]; then _nres="$_res" fi From 01d6d469148ac98949787355ff6156562163154d Mon Sep 17 00:00:00 2001 From: ZeroSSL-Andreas Date: Fri, 19 Jun 2026 17:06:57 +0200 Subject: [PATCH 060/224] New Banner Updated README to include responsive images for dark and light modes. --- README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 44a73e83..b93a8a50 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,21 @@

- - zerossl.com - + + + + + + + + ZeroSSL + +

🔐 acme.sh

From 9900adb0076f88ca943b0f527662aa5d9f208e50 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 19:01:10 +0800 Subject: [PATCH 061/224] fix https://github.com/acmesh-official/acme.sh/issues/4756 --- acme.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/acme.sh b/acme.sh index ad7a6325..1b3af51f 100755 --- a/acme.sh +++ b/acme.sh @@ -7199,6 +7199,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 From 1241649501437ca0603703f4c8190a15b8a23313 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 19:38:21 +0800 Subject: [PATCH 062/224] fix https://github.com/acmesh-official/acme.sh/issues/7009 --- acme.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/acme.sh b/acme.sh index 1b3af51f..5529fe88 100755 --- a/acme.sh +++ b/acme.sh @@ -5838,6 +5838,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" From eabd23a55164c9a75044573584d24a8505666fa1 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 19:38:42 +0800 Subject: [PATCH 063/224] fix https://github.com/acmesh-official/acme.sh/issues/6963 --- dnsapi/dns_namecheap.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_namecheap.sh b/dnsapi/dns_namecheap.sh index 5527b357..cca59735 100755 --- a/dnsapi/dns_namecheap.sh +++ b/dnsapi/dns_namecheap.sh @@ -264,8 +264,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 From ad99628e50e4614c33082114856c92a417c76555 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 20:04:32 +0800 Subject: [PATCH 064/224] fix https://github.com/acmesh-official/acme.sh/issues/6917 --- acme.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/acme.sh b/acme.sh index 5529fe88..1c68845f 100755 --- a/acme.sh +++ b/acme.sh @@ -5751,6 +5751,17 @@ $_authorizations_map" 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 60-day + # schedule, which computes from the creation date and never looks at + # notAfter. https://github.com/acmesh-official/acme.sh/issues/6917 + _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 + _savedomainconf "Le_NextRenewTimeStr" "$Le_NextRenewTimeStr" _savedomainconf "Le_NextRenewTime" "$Le_NextRenewTime" From 92bd80c07daf03b4ed362184f1b4514826ffba0f Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 20:07:42 +0800 Subject: [PATCH 065/224] fix https://github.com/acmesh-official/acme.sh/issues/6914 --- dnsapi/dns_dnsexit.sh | 115 +++++++++--------------------------------- 1 file changed, 25 insertions(+), 90 deletions(-) diff --git a/dnsapi/dns_dnsexit.sh b/dnsapi/dns_dnsexit.sh index ec3b07a4..6b10891c 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":0,"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 } From 7e7c0ee984bd2548453f436382c65de3345ecfe0 Mon Sep 17 00:00:00 2001 From: magyarsz <699745+magyarsz@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:42:52 +0200 Subject: [PATCH 066/224] Merge pull request #6720 from magyarsz/dev Fix a logical error in the `renew` function --- acme.sh | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/acme.sh b/acme.sh index 1c68845f..b65d41ef 100755 --- a/acme.sh +++ b/acme.sh @@ -5936,11 +5936,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 From 5038d12d6278b1fc888f80373f68999bece3b688 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 21:55:23 +0800 Subject: [PATCH 067/224] forbid using --days together with --valid-to https://github.com/acmesh-official/acme.sh/pull/6572 --- acme.sh | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/acme.sh b/acme.sh index b65d41ef..affe7b78 100755 --- a/acme.sh +++ b/acme.sh @@ -5752,14 +5752,18 @@ $_authorizations_map" 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 60-day + # 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. https://github.com/acmesh-official/acme.sh/issues/6917 - _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.")" + # 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" @@ -8568,6 +8572,15 @@ _process() { _debug2 LE_WORKING_DIR "$LE_WORKING_DIR" + # --days and --valid-to are mutually exclusive by design: --valid-to pins + # the cert lifetime and the renewal time follows the expiry, so a + # creation-based --days schedule can not apply. + if [ "$_days" ] && [ "$_valid_to" ]; then + _err "--days can not be used together with --valid-to." + _err "With --valid-to, the renewal time is derived from the expiry time automatically." + return 1 + fi + if [ "$DEBUG" ]; then version if [ "$_server" ]; then From 92a1b4710838866a4a409be593f4fb7df9ffdd21 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 22:26:50 +0800 Subject: [PATCH 068/224] forbid spaces in the --home/--config-home path https://github.com/acmesh-official/acme.sh/issues/2163 --- acme.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/acme.sh b/acme.sh index affe7b78..23adba93 100755 --- a/acme.sh +++ b/acme.sh @@ -2842,6 +2842,17 @@ __initHome() { _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 From 61400500e2ddcf400682aa9dacf27aa891aa9411 Mon Sep 17 00:00:00 2001 From: Trekky12 Date: Fri, 3 Jul 2026 16:31:31 +0200 Subject: [PATCH 069/224] Suppress 'signal process started' message when nginx config is restored (related to issue #4995) (#6747) --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index 23adba93..3cb4e248 100755 --- a/acme.sh +++ b/acme.sh @@ -3524,7 +3524,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 From f4d2db64efb54175d57f0aaa9f4a97c9c1e2c2ab Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 22:49:23 +0800 Subject: [PATCH 070/224] dns_gd: skip readback check when GoDaddy API returns UNKNOWN_DOMAIN https://github.com/acmesh-official/acme.sh/issues/6517 --- dnsapi/dns_gd.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dnsapi/dns_gd.sh b/dnsapi/dns_gd.sh index ee66ee19..e08f2a05 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 From 20254cbaf0755175024b2a9e0aaa87f9d998fc2e Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 23:02:35 +0800 Subject: [PATCH 071/224] dns_dnsimple: support user tokens (dnsimple_u_*) https://github.com/acmesh-official/acme.sh/issues/6491 --- dnsapi/dns_dnsimple.sh | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/dnsapi/dns_dnsimple.sh b/dnsapi/dns_dnsimple.sh index 10a3821d..e262239a 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 ' @@ -28,7 +29,7 @@ dns_dnsimple_add() { _saveaccountconf 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 @@ -57,7 +58,7 @@ dns_dnsimple_rm() { fulldomain=$1 if ! _get_account_id; then - _err "failed to retrive account id" + _err "failed to retrieve account id" return 1 fi @@ -122,13 +123,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 DNSimple_ACCOUNT_ID)}" + if [ "$DNSimple_ACCOUNT_ID" ]; then + _saveaccountconf 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 +141,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 From 0ce8c24736971db8b1a73b07d0d010cbec3cb13e Mon Sep 17 00:00:00 2001 From: szakharchenko Date: Fri, 3 Jul 2026 18:25:38 +0300 Subject: [PATCH 072/224] dev_mythic_beasts: Fix header name: Accepts => Accept (#6428) --- dnsapi/dns_mythic_beasts.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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] From b974bbd6d656e24126838f70bbcdc768db0220e6 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 23:28:19 +0800 Subject: [PATCH 073/224] fix upgrade with a relative --home path https://github.com/acmesh-official/acme.sh/issues/6477 --- acme.sh | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index 3cb4e248..b345de8e 100755 --- a/acme.sh +++ b/acme.sh @@ -2834,11 +2834,31 @@ __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 @@ -7676,7 +7696,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 @@ -7684,6 +7706,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" ) } From ac5624536b1321c8ac47d1b2d25946b9a8032144 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 23:30:16 +0800 Subject: [PATCH 074/224] dns_gd: fix root zone detection for API-restricted accounts https://github.com/acmesh-official/acme.sh/issues/4487 --- dnsapi/dns_gd.sh | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/dnsapi/dns_gd.sh b/dnsapi/dns_gd.sh index e08f2a05..c92bdfa2 100755 --- a/dnsapi/dns_gd.sh +++ b/dnsapi/dns_gd.sh @@ -150,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 @@ -159,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 From 780f2ad5dcf42b9b317192fc4031dd86b5d197d5 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 23:44:12 +0800 Subject: [PATCH 076/224] dns_ali: do not rely on "_url_encode upper-hex" so the signature works with older bundled libraries (e.g. Proxmox VE) https://github.com/acmesh-official/acme.sh/issues/6272 --- dnsapi/dns_ali.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_ali.sh b/dnsapi/dns_ali.sh index 90196c69..62e54e0c 100755 --- a/dnsapi/dns_ali.sh +++ b/dnsapi/dns_ali.sh @@ -69,8 +69,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 +96,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 From 7fb40f0ccf32bc16589c63d7a478b42f3fcb0fe5 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 23:55:23 +0800 Subject: [PATCH 077/224] fix https://github.com/acmesh-official/acme.sh/issues/6609 --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index b345de8e..b4861b4c 100755 --- a/acme.sh +++ b/acme.sh @@ -3888,7 +3888,7 @@ _regAccount() { 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 From 0df051577ca1344b75fdce80840f642a640f3e0c Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 00:06:26 +0800 Subject: [PATCH 078/224] fix https://github.com/acmesh-official/acme.sh/issues/6609 --- acme.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/acme.sh b/acme.sh index b4861b4c..dfc056b8 100755 --- a/acme.sh +++ b/acme.sh @@ -1179,6 +1179,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) @@ -1201,6 +1206,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")" @@ -1214,6 +1220,7 @@ _createkey() { return 1 fi chmod 600 "$f" + _new_key_file="1" fi if _isEccKey "$length"; then @@ -1222,6 +1229,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 @@ -1234,6 +1245,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 From 2229330c48bcf7c9cf866e595bc6588a9574bde0 Mon Sep 17 00:00:00 2001 From: Artur Klauser Date: Fri, 3 Jul 2026 09:32:45 -0700 Subject: [PATCH 079/224] Fix typo in synology_dsm.sh (#6406) Fix typo in an error message. --- deploy/synology_dsm.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index e28a4036..6fce1f19 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -276,7 +276,7 @@ synology_dsm_deploy() { if [ -n "$error_code" ]; 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." + _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 From f4dc9fd9d110a27870b78395ec1741434a82208c Mon Sep 17 00:00:00 2001 From: Laurent Grawet Date: Fri, 3 Jul 2026 18:35:02 +0200 Subject: [PATCH 080/224] haproxy.sh: allows certificate deployment to multiple hosts (#5180) * haproxy.sh: allows certificate deployment to multiple hosts * Update deploy/haproxy.sh Co-authored-by: Matt Simerson * Update deploy/haproxy.sh Co-authored-by: Matt Simerson --------- Co-authored-by: Matt Simerson --- deploy/haproxy.sh | 103 ++++++++++++++++++++++++---------------------- 1 file changed, 53 insertions(+), 50 deletions(-) diff --git a/deploy/haproxy.sh b/deploy/haproxy.sh index 19509e3b..b618a65b 100644 --- a/deploy/haproxy.sh +++ b/deploy/haproxy.sh @@ -43,7 +43,8 @@ # needing to reload HAProxy. Default is "no". # # Require the socat binary. DEPLOY_HAPROXY_STATS_SOCKET variable uses the socat -# address format. +# address format. The certificate can be deployed to a comma separated ',' list +# of hosts ("TCP4:10.0.0.1:1999,TCP4:10.0.0.2:1999") # # export DEPLOY_HAPROXY_MASTER_CLI="UNIX:/run/haproxy-master.sock" # @@ -193,7 +194,6 @@ haproxy_deploy() { _issuer="${_pem}.issuer" _ocsp="${_pem}.ocsp" _reload="${Le_Deploy_haproxy_reload}" - _statssock="${Le_Deploy_haproxy_stats_socket}" _info "Deploying PEM file" # Create a temporary PEM file @@ -327,62 +327,65 @@ haproxy_deploy() { # Update certificate over HAProxy stats socket or master CLI. if _exists socat; then - # look for the certificate on the stats socket, to chose between updating or creating one - _socat_cert_cmd="echo '${_cmdpfx}show ssl cert' | socat '${_statssock}' - | grep -q '^${_pem}$'" - _debug _socat_cert_cmd "${_socat_cert_cmd}" - eval "${_socat_cert_cmd}" - _ret=$? - if [ "${_ret}" != "0" ]; then - _newcert="1" - _info "Creating new certificate '${_pem}' over HAProxy ${_socketname}." - # certificate wasn't found, it's a new one. We should check if the crt-list exists and creates/inserts the certificate. - _socat_crtlist_show_cmd="echo '${_cmdpfx}show ssl crt-list' | socat '${_statssock}' - | grep -q '^${Le_Deploy_haproxy_pem_path}$'" - _debug _socat_crtlist_show_cmd "${_socat_crtlist_show_cmd}" - eval "${_socat_crtlist_show_cmd}" + IFS=',' + for _statssock in ${Le_Deploy_haproxy_stats_socket}; do + # look for the certificate on the stats socket, to choose between updating or creating one + _socat_cert_cmd="echo '${_cmdpfx}show ssl cert' | socat '${_statssock}' - | grep -q '^${_pem}$'" + _debug _socat_cert_cmd "${_socat_cert_cmd}" + eval "${_socat_cert_cmd}" _ret=$? if [ "${_ret}" != "0" ]; then - _err "Couldn't find '${Le_Deploy_haproxy_pem_path}' in haproxy 'show ssl crt-list'" - return "${_ret}" + _newcert="1" + _info "Creating new certificate '${_pem}' over HAProxy ${_socketname}." + # certificate wasn't found, it's a new one. We should check if the crt-list exists and creates/inserts the certificate. + _socat_crtlist_show_cmd="echo '${_cmdpfx}show ssl crt-list' | socat '${_statssock}' - | grep -q '^${Le_Deploy_haproxy_pem_path}$'" + _debug _socat_crtlist_show_cmd "${_socat_crtlist_show_cmd}" + eval "${_socat_crtlist_show_cmd}" + _ret=$? + if [ "${_ret}" != "0" ]; then + _err "Couldn't find '${Le_Deploy_haproxy_pem_path}' in haproxy 'show ssl crt-list'" + return "${_ret}" + fi + # create a new certificate + _socat_new_cmd="echo '${_cmdpfx}new ssl cert ${_pem}' | socat '${_statssock}' - | grep -q 'New empty'" + _debug _socat_new_cmd "${_socat_new_cmd}" + eval "${_socat_new_cmd}" + _ret=$? + if [ "${_ret}" != "0" ]; then + _err "Couldn't create '${_pem}' in haproxy" + return "${_ret}" + fi + else + _info "Update existing certificate '${_pem}' over HAProxy ${_socketname}." fi - # create a new certificate - _socat_new_cmd="echo '${_cmdpfx}new ssl cert ${_pem}' | socat '${_statssock}' - | grep -q 'New empty'" - _debug _socat_new_cmd "${_socat_new_cmd}" - eval "${_socat_new_cmd}" - _ret=$? - if [ "${_ret}" != "0" ]; then - _err "Couldn't create '${_pem}' in haproxy" - return "${_ret}" - fi - else - _info "Update existing certificate '${_pem}' over HAProxy ${_socketname}." - fi - _socat_cert_set_cmd="echo -e '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -q 'Transaction created'" - _secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}" - eval "${_socat_cert_set_cmd}" - _ret=$? - if [ "${_ret}" != "0" ]; then - _err "Can't update '${_pem}' in haproxy" - return "${_ret}" - fi - _socat_cert_commit_cmd="echo '${_cmdpfx}commit ssl cert ${_pem}' | socat '${_statssock}' - | grep -q '^Success!$'" - _debug _socat_cert_commit_cmd "${_socat_cert_commit_cmd}" - eval "${_socat_cert_commit_cmd}" - _ret=$? - if [ "${_ret}" != "0" ]; then - _err "Can't commit '${_pem}' in haproxy" - return ${_ret} - fi - if [ "${_newcert}" = "1" ]; then - # if this is a new certificate, it needs to be inserted into the crt-list` - _socat_cert_add_cmd="echo '${_cmdpfx}add ssl crt-list ${Le_Deploy_haproxy_pem_path} ${_pem}' | socat '${_statssock}' - | grep -q 'Success!'" - _debug _socat_cert_add_cmd "${_socat_cert_add_cmd}" - eval "${_socat_cert_add_cmd}" + _socat_cert_set_cmd="echo -e '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -q 'Transaction created'" + _secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}" + eval "${_socat_cert_set_cmd}" _ret=$? if [ "${_ret}" != "0" ]; then _err "Can't update '${_pem}' in haproxy" return "${_ret}" fi - fi + _socat_cert_commit_cmd="echo '${_cmdpfx}commit ssl cert ${_pem}' | socat '${_statssock}' - | grep -q '^Success!$'" + _debug _socat_cert_commit_cmd "${_socat_cert_commit_cmd}" + eval "${_socat_cert_commit_cmd}" + _ret=$? + if [ "${_ret}" != "0" ]; then + _err "Can't commit '${_pem}' in haproxy" + return ${_ret} + fi + if [ "${_newcert}" = "1" ]; then + # if this is a new certificate, it needs to be inserted into the crt-list` + _socat_cert_add_cmd="echo '${_cmdpfx}add ssl crt-list ${Le_Deploy_haproxy_pem_path} ${_pem}' | socat '${_statssock}' - | grep -q 'Success!'" + _debug _socat_cert_add_cmd "${_socat_cert_add_cmd}" + eval "${_socat_cert_add_cmd}" + _ret=$? + if [ "${_ret}" != "0" ]; then + _err "Can't update '${_pem}' in haproxy" + return "${_ret}" + fi + fi + done else _err "'socat' is not available, couldn't update over ${_socketname}" fi From 0a6abaf8a1336a2bc33c1847850176ef0cb7471d Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 00:39:35 +0800 Subject: [PATCH 081/224] fix https://github.com/acmesh-official/acme.sh/issues/6388 --- acme.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index dfc056b8..5a41f16b 100755 --- a/acme.sh +++ b/acme.sh @@ -6381,7 +6381,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" From b4de9e86217281eff474a2af7b40620618c1f869 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 10:49:32 +0800 Subject: [PATCH 082/224] fix docker deploy hook on podman, check exec ExitCode instead of response body (#4977) --- deploy/docker.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/deploy/docker.sh b/deploy/docker.sh index 264963ae..7fdcf604 100755 --- a/deploy/docker.sh +++ b/deploy/docker.sh @@ -189,10 +189,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 From 6cd0c00a21ecea6b4f1f25c4b715c1f2282c4cff Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 11:23:11 +0800 Subject: [PATCH 083/224] extract authorizations parsing into _authorizations_from_order, fix IPv6 urls (#6326) --- acme.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index 5a41f16b..a21bfd71 100755 --- a/acme.sh +++ b/acme.sh @@ -918,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" @@ -4981,7 +4990,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." @@ -6839,7 +6848,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." From 7653eaab31e3519bf03cd46ff3a22f72f55e7651 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 11:44:32 +0800 Subject: [PATCH 084/224] fix https://github.com/acmesh-official/acme.sh/issues/4879#issuecomment-2942728895 --- dnsapi/dns_hostingde.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dnsapi/dns_hostingde.sh b/dnsapi/dns_hostingde.sh index 41ccab2b..ed675b42 100644 --- a/dnsapi/dns_hostingde.sh +++ b/dnsapi/dns_hostingde.sh @@ -40,6 +40,11 @@ _hostingde_apiKey() { return 1 fi + # The endpoint is the base URL only; the api path is appended below. + # hosting.de's own docs show the full api URL, so strip it if pasted in. + # https://github.com/acmesh-official/acme.sh/issues/6896 + HOSTINGDE_ENDPOINT="$(echo "$HOSTINGDE_ENDPOINT" | sed 's|/api/dns/v1/json||; s|/*$||')" + _saveaccountconf_mutable HOSTINGDE_APIKEY "$HOSTINGDE_APIKEY" _saveaccountconf_mutable HOSTINGDE_ENDPOINT "$HOSTINGDE_ENDPOINT" } From e64529ab501b217ba453ee43144c478ef3288ab7 Mon Sep 17 00:00:00 2001 From: "Simon V." <218359733+sim0n-v@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:50:55 +0200 Subject: [PATCH 085/224] ARI - Add support for Mass Revocation (#6953) * ARI - Add support for Mass Revocation * feat: update ARI each time NextRenewTime is not within the suggestedWindow * Remove _ari_should_renew and add condition on Le_NextRenewTime * Add support for ARI explanationURL * Fix debug variable _d_ari * New Banner Updated README to include responsive images for dark and light modes. * multiple fix * fix * fix shfmt * Reset README --------- Co-authored-by: ZeroSSL-Andreas --- acme.sh | 47 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/acme.sh b/acme.sh index a21bfd71..8e8186e0 100755 --- a/acme.sh +++ b/acme.sh @@ -5928,7 +5928,6 @@ renew() { # 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. - _ari_should_renew="" if [ "$NO_ARI" = "1" ]; then _debug "NO_ARI=1, skipping ARI suggestedWindow check" elif [ -z "$FORCE" ] && [ -f "$CERT_PATH" ]; then @@ -5939,20 +5938,38 @@ renew() { _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" ]; then + 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" - if [ "$_ari_start_t" ] && [ "$(_time)" -ge "$_ari_start_t" ]; then - _info "ARI suggestedWindow has started ($(__green "$_ari_start")), proceeding with renewal." - _ari_should_renew="1" - else - _info "ARI suggestedWindow starts at: $(__green "$_ari_start")" + _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" ] && [ -z "$_ari_should_renew" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then + if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then _info "Skipping. Next renewal time is: $(__green "$Le_NextRenewTimeStr")" _info "Add '$(__red '--force')' to force renewal." if [ -z "$_ACME_IN_RENEWALL" ]; then @@ -6044,12 +6061,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" @@ -6064,8 +6088,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 From 8f2a476d21c13cde3b0c97ed0074c8d6b259d3f4 Mon Sep 17 00:00:00 2001 From: wardhus Date: Sat, 4 Jul 2026 10:54:05 +0200 Subject: [PATCH 086/224] Add Calrissia.be API (#6811) Co-authored-by: Ward --- dnsapi/dns_calrissia.sh | 137 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 dnsapi/dns_calrissia.sh diff --git a/dnsapi/dns_calrissia.sh b/dnsapi/dns_calrissia.sh new file mode 100644 index 00000000..01ca3092 --- /dev/null +++ b/dnsapi/dns_calrissia.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_calrissia_info='Calrissia.be DNS API +Site: calrissia.be +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_calrissia +Options: + CALRISSIA_TOKEN Personal access token +Issues: github.com/acmesh-official/acme.sh/issues/6809 +Author: Ward Hus +' + +CALRISSIA_API="https://my.calrissia.com/api" + +dns_calrissia_add() { + fulldomain="$1" + txtvalue="$2" + + _calrissia_load_token || return 1 + + if ! _calrissia_get_root "$fulldomain"; then + _err "Unable to find domain in Calrissia account for: $fulldomain" + return 1 + fi + + _debug "domain='$_domain' id='$_domain_id' sub='$_sub_domain'" + _info "Adding TXT record for $fulldomain" + + _body="{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"ttl\":120,\"prio\":0}" + _response="$(_calrissia_request POST "/domain/$_domain_id/record" "$_body")" + + if ! _contains "$_response" '"id"'; then + _err "Failed to create TXT record: $_response" + return 1 + fi + + return 0 +} + +dns_calrissia_rm() { + fulldomain="$1" + txtvalue="$2" + + _calrissia_load_token || return 1 + + if ! _calrissia_get_root "$fulldomain"; then + _err "Unable to find domain in Calrissia account for: $fulldomain" + return 1 + fi + + _debug "domain='$_domain' id='$_domain_id' sub='$_sub_domain'" + + # Look the record up from the API instead of relying on local state. + # The record list is embedded in the domain object. + _response="$(_calrissia_request GET "/domain/$_domain_id")" + _debug2 "Response: $_response" + + # Split the record objects onto separate lines, then match on both the + # subdomain name and the TXT value to find the record id to delete. + _record_id="$(printf "%s" "$_response" | + tr '{}' '\n' | + grep "\"name\" *: *\"$_sub_domain\"" | + grep "\"content\" *: *\"$txtvalue\"" | + _egrep_o '"id" *: *[0-9]+' | + _head_n 1 | + _egrep_o '[0-9]+')" + + if [ -z "$_record_id" ]; then + _info "No matching TXT record found for $fulldomain; nothing to remove" + return 0 + fi + + _info "Removing TXT record id=$_record_id from domain id=$_domain_id" + if ! _response="$(_calrissia_request DELETE "/domain/$_domain_id/record/$_record_id")" || _contains "$_response" '"error"'; then + _err "Failed to remove TXT record: $_response" + return 1 + fi + return 0 +} + +#################### +# Private helpers # +#################### + +_calrissia_load_token() { + CALRISSIA_TOKEN="${CALRISSIA_TOKEN:-$(_readaccountconf_mutable CALRISSIA_TOKEN)}" + if [ -z "$CALRISSIA_TOKEN" ]; then + _err "CALRISSIA_TOKEN is not set. Generate one at https://identity.calrissia.com under API Keys." + return 1 + fi + _saveaccountconf_mutable CALRISSIA_TOKEN "$CALRISSIA_TOKEN" +} + +# Sets _domain, _domain_id, _sub_domain for a given FQDN. +_calrissia_get_root() { + _fqdn="$1" + + i=1 + while true; do + _candidate="$(printf "%s" "$_fqdn" | cut -d . -f "$i"-)" + [ -z "$_candidate" ] && return 1 + + _debug "Trying root domain: $_candidate" + _response="$(_calrissia_request GET "/domain?full_domain_name=$_candidate")" + _debug2 "Response: $_response" + + _domain_id="$(printf "%s" "$_response" | + _egrep_o '"id" *: *[0-9]+' | + _head_n 1 | + _egrep_o '[0-9]+')" + + if [ -n "$_domain_id" ]; then + if [ "$i" = "1" ]; then + # The FQDN itself is the zone apex, e.g. a challenge-alias domain. + _sub_domain="" + else + _sub_domain="$(printf "%s" "$_fqdn" | cut -d . -f "1-$((i - 1))")" + fi + _domain="$_candidate" + return 0 + fi + + i=$((i + 1)) + done +} + +_calrissia_request() { + _method="$1" + _path="$2" + _body="$3" + export _H1="Authorization: Bearer $CALRISSIA_TOKEN" + export _H2="Accept: application/json" + if [ "$_method" = "GET" ]; then + _get "$CALRISSIA_API$_path" + else + _post "$_body" "$CALRISSIA_API$_path" "" "$_method" "application/json" + fi +} From 1a3682346166989a02894b4fa401ec9127445938 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 16:44:07 +0800 Subject: [PATCH 087/224] https://github.com/acmesh-official/acme.sh/issues/3201 --- deploy/synology_dsm.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index 6fce1f19..502bc59b 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -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 From 6df2d9e451443ac3d935b7dfcb14b6bd146dea7e Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 18:19:45 +0800 Subject: [PATCH 088/224] dns_da: document that special characters in DA_Api credentials must be percent-encoded https://github.com/acmesh-official/acme.sh/issues/3468 --- dnsapi/dns_da.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ' From bbfb6f50aec5bddb397b5f852c5d0d1f3d5690df Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 18:46:59 +0800 Subject: [PATCH 089/224] deploy/cpanel_uapi: strip YAML double quotes around wildcard domains in list_domains output fix https://github.com/acmesh-official/acme.sh/issues/6115 --- deploy/cpanel_uapi.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/cpanel_uapi.sh b/deploy/cpanel_uapi.sh index e5381b61..16b622bb 100644 --- a/deploy/cpanel_uapi.sh +++ b/deploy/cpanel_uapi.sh @@ -194,7 +194,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 From bcbfe25d08b90a133ac9da87c832889eb1975fe0 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 18:50:29 +0800 Subject: [PATCH 090/224] haproxy.sh: use two-argument -header form for LibreSSL (#3438) --- deploy/haproxy.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/deploy/haproxy.sh b/deploy/haproxy.sh index b618a65b..66a2e83e 100644 --- a/deploy/haproxy.sh +++ b/deploy/haproxy.sh @@ -272,12 +272,18 @@ haproxy_deploy() { _cafile_argument="" fi _debug _cafile_argument "${_cafile_argument}" - # if OpenSSL/LibreSSL is v1.1 or above, the format for the -header option has changed + # OpenSSL 1.1+ expects -header Host=value (one argument), while + # LibreSSL keeps the old two-argument form -header Host value at any + # version (3.x/4.x), so it must be detected by name, not by number. + _openssl_name=$(${ACME_OPENSSL_BIN:-openssl} version | cut -d' ' -f1) _openssl_version=$(${ACME_OPENSSL_BIN:-openssl} version | cut -d' ' -f2) + _debug _openssl_name "${_openssl_name}" _debug _openssl_version "${_openssl_version}" _openssl_major=$(echo "${_openssl_version}" | cut -d '.' -f1) _openssl_minor=$(echo "${_openssl_version}" | cut -d '.' -f2) - if [ "${_openssl_major}" -eq "1" ] && [ "${_openssl_minor}" -ge "1" ] || [ "${_openssl_major}" -ge "2" ]; then + if [ "${_openssl_name}" = "LibreSSL" ]; then + _header_sep=" " + elif [ "${_openssl_major}" -eq "1" ] && [ "${_openssl_minor}" -ge "1" ] || [ "${_openssl_major}" -ge "2" ]; then _header_sep="=" else _header_sep=" " From 33704fc27479a86d8aa6e6b14c27d9b3ab82b87d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Gouin?= Date: Sat, 4 Jul 2026 12:54:07 +0200 Subject: [PATCH 091/224] Allow creation of ACME account with EAB directly from `--issue` command (#5087) * formalized _eab_id and _eab_kid and added EAB parameters to _regAccount on --issue * Update acme.sh * Update acme.sh --- acme.sh | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/acme.sh b/acme.sh index 8e8186e0..6bf59fbb 100755 --- a/acme.sh +++ b/acme.sh @@ -3872,10 +3872,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() { @@ -3905,7 +3905,7 @@ _getAccountEmail() { _regAccount() { _initpath _reg_length="$1" - _eab_id="$2" + _eab_kid="$2" _eab_hmac_key="$3" _debug3 _regAccount "$_regAccount" _initAPI @@ -3922,13 +3922,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 @@ -3936,7 +3936,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.")" @@ -3952,10 +3952,10 @@ _regAccount() { 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 '"')" @@ -3964,12 +3964,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) @@ -4798,7 +4798,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 From 917bebd46033fad6a167d768e9c04cc82520bff3 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 19:06:26 +0800 Subject: [PATCH 092/224] dns_huaweicloud: add optional HUAWEICLOUD_Region (default ap-southeast-1) The DNS endpoint and IAM token scope project were hardcoded to ap-southeast-1, which fails for accounts without that region enabled. fix https://github.com/acmesh-official/acme.sh/issues/5302 --- dnsapi/dns_huaweicloud.sh | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/dnsapi/dns_huaweicloud.sh b/dnsapi/dns_huaweicloud.sh index ee2d2b8e..83fcc625 100644 --- a/dnsapi/dns_huaweicloud.sh +++ b/dnsapi/dns_huaweicloud.sh @@ -7,11 +7,11 @@ Options: HUAWEICLOUD_Username Username HUAWEICLOUD_Password Password HUAWEICLOUD_DomainName DomainName + HUAWEICLOUD_Region Region. E.g. "cn-north-4". Optional, defaults to "ap-southeast-1". Issues: github.com/acmesh-official/acme.sh/issues/3265 ' iam_api="https://iam.myhuaweicloud.com" -dns_api="https://dns.ap-southeast-1.myhuaweicloud.com" # Should work ######## Public functions ##################### @@ -30,6 +30,7 @@ dns_huaweicloud_add() { HUAWEICLOUD_Username="${HUAWEICLOUD_Username:-$(_readaccountconf_mutable HUAWEICLOUD_Username)}" HUAWEICLOUD_Password="${HUAWEICLOUD_Password:-$(_readaccountconf_mutable HUAWEICLOUD_Password)}" HUAWEICLOUD_DomainName="${HUAWEICLOUD_DomainName:-$(_readaccountconf_mutable HUAWEICLOUD_DomainName)}" + HUAWEICLOUD_Region="${HUAWEICLOUD_Region:-$(_readaccountconf_mutable HUAWEICLOUD_Region)}" # Check information if [ -z "${HUAWEICLOUD_Username}" ] || [ -z "${HUAWEICLOUD_Password}" ] || [ -z "${HUAWEICLOUD_DomainName}" ]; then @@ -37,8 +38,11 @@ dns_huaweicloud_add() { return 1 fi + _huaweicloud_region="${HUAWEICLOUD_Region:-ap-southeast-1}" + dns_api="https://dns.${_huaweicloud_region}.myhuaweicloud.com" + unset token # Clear token - token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}")" + token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}" "${_huaweicloud_region}")" if [ -z "${token}" ]; then # Check token _err "dns_api(dns_huaweicloud): Error getting token." return 1 @@ -65,6 +69,9 @@ dns_huaweicloud_add() { _saveaccountconf_mutable HUAWEICLOUD_Username "${HUAWEICLOUD_Username}" _saveaccountconf_mutable HUAWEICLOUD_Password "${HUAWEICLOUD_Password}" _saveaccountconf_mutable HUAWEICLOUD_DomainName "${HUAWEICLOUD_DomainName}" + if [ -n "${HUAWEICLOUD_Region}" ]; then + _saveaccountconf_mutable HUAWEICLOUD_Region "${HUAWEICLOUD_Region}" + fi return 0 } @@ -81,6 +88,7 @@ dns_huaweicloud_rm() { HUAWEICLOUD_Username="${HUAWEICLOUD_Username:-$(_readaccountconf_mutable HUAWEICLOUD_Username)}" HUAWEICLOUD_Password="${HUAWEICLOUD_Password:-$(_readaccountconf_mutable HUAWEICLOUD_Password)}" HUAWEICLOUD_DomainName="${HUAWEICLOUD_DomainName:-$(_readaccountconf_mutable HUAWEICLOUD_DomainName)}" + HUAWEICLOUD_Region="${HUAWEICLOUD_Region:-$(_readaccountconf_mutable HUAWEICLOUD_Region)}" # Check information if [ -z "${HUAWEICLOUD_Username}" ] || [ -z "${HUAWEICLOUD_Password}" ] || [ -z "${HUAWEICLOUD_DomainName}" ]; then @@ -88,8 +96,11 @@ dns_huaweicloud_rm() { return 1 fi + _huaweicloud_region="${HUAWEICLOUD_Region:-ap-southeast-1}" + dns_api="https://dns.${_huaweicloud_region}.myhuaweicloud.com" + unset token # Clear token - token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}")" + token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}" "${_huaweicloud_region}")" if [ -z "${token}" ]; then # Check token _err "dns_api(dns_huaweicloud): Error getting token." return 1 @@ -298,6 +309,7 @@ _get_token() { _username=$1 _password=$2 _domain_name=$3 + _region_name=$4 _debug "Getting Token" body="{ @@ -318,7 +330,7 @@ _get_token() { }, \"scope\": { \"project\": { - \"name\": \"ap-southeast-1\" + \"name\": \"${_region_name}\" } } } From a1051260635487670f28188156caec4f6dfe40fe Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 19:14:36 +0800 Subject: [PATCH 093/224] _date2time: pass date via argv to python to prevent code injection (#6463) https://github.com/acmesh-official/acme.sh/issues/6463 --- acme.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index 6bf59fbb..148945b9 100755 --- a/acme.sh +++ b/acme.sh @@ -1895,12 +1895,13 @@ _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" @@ -2992,6 +2993,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 } From d3e12694b9a945664c963b3336270273d309e991 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 19:56:37 +0800 Subject: [PATCH 094/224] fix "identifiers are duplicated" when signing a CSR with a wildcard CN also present in SAN _contains matches with grep regex, so the '*' in "DNS:*.example.com," never matched and the subject was appended to the identifiers a second time. Escape the wildcard before the check, the same way the sed removal already does. fix https://github.com/acmesh-official/acme.sh/issues/5251 --- acme.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/acme.sh b/acme.sh index 148945b9..8256d5f0 100755 --- a/acme.sh +++ b/acme.sh @@ -1423,12 +1423,13 @@ _readSubjectAltNamesFromCSR() { _dnsAltnames="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" | 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 + _excapedAlgnames="$(echo "$_dnsAltnames" | tr '*' '#')" + _debug _excapedAlgnames "$_excapedAlgnames" + _escapedSubject="$(echo "$_csrsubj" | tr '*' '#')" + _debug _escapedSubject "$_escapedSubject" + if _contains "$_excapedAlgnames," "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")" _debug _dnsAltnames "$_dnsAltnames" else From 843a7efa7ddd2b069c96249de6261100e08b6af5 Mon Sep 17 00:00:00 2001 From: xiaopc Date: Sat, 4 Jul 2026 20:14:32 +0800 Subject: [PATCH 095/224] fix(gcore_cdn): renew login api url (#5143) https://api.gcore.com/docs/iam#tag/Account --- deploy/gcore_cdn.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/gcore_cdn.sh b/deploy/gcore_cdn.sh index fd17cc25..93e9e32c 100644 --- a/deploy/gcore_cdn.sh +++ b/deploy/gcore_cdn.sh @@ -57,7 +57,7 @@ gcore_cdn_deploy() { _request="{\"username\":\"$Le_Deploy_gcore_cdn_username\",\"password\":\"$Le_Deploy_gcore_cdn_password\"}" _debug _request "$_request" export _H1="Content-Type:application/json" - _response=$(_post "$_request" "https://api.gcore.com/auth/jwt/login") + _response=$(_post "$_request" "https://api.gcore.com/iam/auth/jwt/login") _debug _response "$_response" _regex=".*\"access\":\"\([-._0-9A-Za-z]*\)\".*$" _debug _regex "$_regex" From ede9a86d46d93f56d5b36ce2873c0d19489604bc Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 20:21:17 +0800 Subject: [PATCH 096/224] Accept both 401 and 403 for deactivated account detection RFC 8555 sec 7.3.6 requires 401 (Unauthorized) when a request is signed by a deactivated account, which ZeroSSL follows, while Boulder (Let's Encrypt) historically returns 403. Check both codes in _regAccount and deactivateaccount. fix https://github.com/acmesh-official/acme.sh/issues/5138 --- acme.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index 8256d5f0..e7e9eb66 100755 --- a/acme.sh +++ b/acme.sh @@ -4042,7 +4042,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 @@ -4122,7 +4124,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 From fbf3b41c541694348584024b5d6703b3ddfbde95 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 20:28:56 +0800 Subject: [PATCH 097/224] dns_inwx: fix _get_root false zone match for single-letter subdomains _get_root matched the candidate zone with _contains (grep), which treats the domain as a regex. For "-d g." the candidate "g." matched "..." because '.' matches the '>' after "string" and the 'g' comes from the "" tag, so "g." was wrongly taken as the root zone (sub=_acme-challenge instead of _acme-challenge.g). Anchor the match to $h and escape dots so the zone is compared literally. Fixes #5129 --- dnsapi/dns_inwx.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_inwx.sh b/dnsapi/dns_inwx.sh index dba23846..460d4d28 100755 --- a/dnsapi/dns_inwx.sh +++ b/dnsapi/dns_inwx.sh @@ -307,13 +307,18 @@ _get_root() { return 1 fi - if _contains "$response" "$h"; then + # Anchor the match to the XML tag and escape dots so $h is compared + # literally: _contains uses grep, which treats "$h" as a regex, and a + # bare "g.berlight.de" would match "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. + # 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 From b92516f79edef6a286dd8ba52305b8d541830410 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sat, 4 Jul 2026 15:07:18 +0200 Subject: [PATCH 098/224] add application/json to acmedns (#5066) --- dnsapi/dns_acmedns.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_acmedns.sh b/dnsapi/dns_acmedns.sh index f3f50233..b109a4e5 100755 --- a/dnsapi/dns_acmedns.sh +++ b/dnsapi/dns_acmedns.sh @@ -71,7 +71,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 From 988afd0f59545ca6cc57b9317701c3156934e261 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 21:34:35 +0800 Subject: [PATCH 099/224] _isIPv4: do not glob segments, require exactly 4 octets The unquoted splitting let a "*" segment expand against files in the current directory, so "*.*.*.*" could pass as a valid IPv4 address (issue 4971). The old code also accepted "", "1.2.3", "1.2.3.4.5", "1..2.3" and bare numbers. Split with IFS under set -f, require 4 octets, and validate each as a 1-3 digit number <= 255. Based on https://github.com/acmesh-official/acme.sh/pull/4974 fix https://github.com/acmesh-official/acme.sh/issues/4971 --- acme.sh | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/acme.sh b/acme.sh index e7e9eb66..b62b19cf 100755 --- a/acme.sh +++ b/acme.sh @@ -4598,16 +4598,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 } From 9764f67619065a5aaa6869a595eac30c9286357c Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 21:58:57 +0800 Subject: [PATCH 100/224] dns_cn: convert IDN domain to punycode before API calls Core-Networks' API rejects Unicode domain names with "invalid domain"; it requires punycode. dns_cn_add / dns_cn_rm passed the raw challenge domain straight through, so IDN certs failed at the TXT add step (issue #4804). Run fulldomain through _idn() in both functions. For ASCII/punycode input _idn() is a pass-through, so non-IDN domains are unaffected. Fixes #4804 --- dnsapi/dns_cn.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_cn.sh b/dnsapi/dns_cn.sh index 79698e88..e06a2be6 100644 --- a/dnsapi/dns_cn.sh +++ b/dnsapi/dns_cn.sh @@ -15,7 +15,8 @@ CN_API="https://beta.api.core-networks.de" ######## Public functions ##################### dns_cn_add() { - fulldomain=$1 + # Core-Networks API requires punycode for IDN domains + fulldomain=$(_idn "$1") txtvalue=$2 if ! _cn_login; then @@ -58,7 +59,8 @@ dns_cn_add() { } dns_cn_rm() { - fulldomain=$1 + # Core-Networks API requires punycode for IDN domains + fulldomain=$(_idn "$1") txtvalue=$2 if ! _cn_login; then From 4978782fb8df3a72d2f79a8360123f872be8a3df Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 23:40:31 +0800 Subject: [PATCH 101/224] renewAll: error out if CERT_HOME is not a directory With a misconfigured $HOME / CERT_HOME the glob over "$CERT_HOME"/*.* matches nothing, so renewAll silently does nothing and returns success -- --renew-all / --cron appears to work while renewing no certificates. Check that CERT_HOME is a directory up front and return 1 with a clear error instead. Closes #4508 --- acme.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/acme.sh b/acme.sh index b62b19cf..4ac94da3 100755 --- a/acme.sh +++ b/acme.sh @@ -6072,6 +6072,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 From 77047eb0efaf21b0019b8389891b00388f830eee Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 23:55:16 +0800 Subject: [PATCH 102/224] fix CSR reading on systems without a default openssl.cnf (e.g. NetBSD) "openssl req -noout -in" aborts when the default config file is missing; reading a CSR needs no config, so pass -config /dev/null explicitly. Stock NetBSD does not install /etc/openssl/openssl.cnf, so --signcsr never worked there. --- acme.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index 4ac94da3..4bc4b2ba 100755 --- a/acme.sh +++ b/acme.sh @@ -1405,7 +1405,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 @@ -1420,7 +1422,7 @@ _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" # escape the wildcard '*' so it is not taken as a regex operator by grep/sed below @@ -1447,7 +1449,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" From 0eb5cc8384c306fc3adcbbe8d00bb85608751749 Mon Sep 17 00:00:00 2001 From: Foster Snowhill Date: Sun, 5 Jul 2026 06:03:48 +0200 Subject: [PATCH 103/224] dns_desec: fix advertised token variable name (#7081) This must've been a copy-paste error from `dns_ddnss`. Fixes: 6b7b5caf54ea ("DNS provider API: structured description") --- dnsapi/dns_desec.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_desec.sh b/dnsapi/dns_desec.sh index 275babea..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 ' From 524d96a3a8004eb7674bd74b1ba7b6f67f4e808a Mon Sep 17 00:00:00 2001 From: Jan Forman <47356271+jforman96@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:44:07 +0200 Subject: [PATCH 104/224] Add WEDOS WAPI DNS API (dns_wedos) (#7072) * Add WEDOS WAPI DNS API (dns_wedos) * dns_wedos: fix response parsing on systems without egrep -o * dns_wedos: report WAPI auth errors, UTC fallback for hosts ignoring TZ --- dnsapi/dns_wedos.sh | 217 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 dnsapi/dns_wedos.sh 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 +} From 1e2cd50fc9759d046ef16d8dd7ada263fe257078 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 15:44:12 +0800 Subject: [PATCH 105/224] deploy/haproxy: use printf instead of "echo -e" for the stats socket payload dash's echo has no -e flag and sends a literal "-e " prefix to the socket, so haproxy rejects the command and the hot update always fails on Debian/Ubuntu (/bin/sh = dash). Also accept "Transaction updated", which haproxy replies when an uncommitted transaction already exists. fix https://github.com/acmesh-official/acme.sh/issues/6165 --- deploy/haproxy.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deploy/haproxy.sh b/deploy/haproxy.sh index 66a2e83e..9736e6ff 100644 --- a/deploy/haproxy.sh +++ b/deploy/haproxy.sh @@ -364,7 +364,9 @@ haproxy_deploy() { else _info "Update existing certificate '${_pem}' over HAProxy ${_socketname}." fi - _socat_cert_set_cmd="echo -e '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -q 'Transaction created'" + # printf %b, not "echo -e": dash's echo has no -e and sends a literal "-e " to the socket. + # "Transaction updated" is replied instead of "created" when an uncommitted transaction exists. + _socat_cert_set_cmd="printf '%b\n' '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -qE 'Transaction (created|updated)'" _secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}" eval "${_socat_cert_set_cmd}" _ret=$? From 31b13caf8bfe8f95a49b19662d507280368eeba0 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 15:51:38 +0800 Subject: [PATCH 106/224] DNS.yml: fix workflow warnings - replace deprecated set-output with GITHUB_OUTPUT - untap aws/tap before brew install to silence tap trust warning - inject safe.directory=* for cygwin git so the checkout post step no longer fails with dubious ownership (exit 128) --- .github/workflows/DNS.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index a972ae1a..5417068f 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -26,9 +26,9 @@ jobs: id: step_one run: | if [ "${{secrets.TokenName1}}" ] ; then - echo "::set-output name=hasToken::true" + echo "hasToken=true" >> "$GITHUB_OUTPUT" else - echo "::set-output name=hasToken::false" + echo "hasToken=false" >> "$GITHUB_OUTPUT" fi - name: Check the value run: echo ${{ steps.step_one.outputs.hasToken }} @@ -116,7 +116,9 @@ jobs: steps: - uses: actions/checkout@v6 - name: Install tools - run: brew install socat + run: | + brew untap aws/tap || true + brew install socat - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - name: Run acmetest @@ -176,9 +178,14 @@ jobs: C:\tools\cygwin\cygwinsetup.exe -qgnNdO -R C:/tools/cygwin -s https://mirrors.kernel.org/sourceware/cygwin/ -P socat,curl,cron,unzip,git shell: cmd - name: Set ENV - shell: cmd + shell: bash run: | - echo PATH=C:\tools\cygwin\bin;C:\tools\cygwin\usr\bin >> %GITHUB_ENV% + echo 'PATH=C:\tools\cygwin\bin;C:\tools\cygwin\usr\bin' >> "$GITHUB_ENV" + # cygwin git sees the runner workspace as owned by another user and + # fails with "dubious ownership" (exit 128) in the checkout post step + echo 'GIT_CONFIG_COUNT=1' >> "$GITHUB_ENV" + echo 'GIT_CONFIG_KEY_0=safe.directory' >> "$GITHUB_ENV" + echo 'GIT_CONFIG_VALUE_0=*' >> "$GITHUB_ENV" - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - name: Run acmetest From 1cd63e1480bbb692a937e1e04709971b5e2e9382 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:02:52 +0800 Subject: [PATCH 107/224] _createcsr: omit CN from the CSR subject when it exceeds 64 characters (#4867) --- acme.sh | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/acme.sh b/acme.sh index 4bc4b2ba..98f237a7 100755 --- a/acme.sh +++ b/acme.sh @@ -1307,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" @@ -1370,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 } From 24895a15c864adfb461bfe6026a2b934da78060f Mon Sep 17 00:00:00 2001 From: laineus Date: Sun, 5 Jul 2026 17:05:33 +0900 Subject: [PATCH 108/224] Add dns_muumuu: muumuu-domain.com DNS API (#7012) * Add dns_muumuu: muumuu-domain.com DNS API * Fix: remove local keyword for POSIX sh compatibility * Fix: lowercase fulldomain for API compatibility * Style: use echo instead of printf for lower_case (consistent with other plugins) * Fix: prefix rest vars, clear _H4/_H5, guard record_id, update Issues URL --- dnsapi/dns_muumuu.sh | 167 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100755 dnsapi/dns_muumuu.sh 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 +} From cacafc9c23947b5a5d4c2b2ddad56eca9e56d1da Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:18:27 +0800 Subject: [PATCH 109/224] add Nginx workflow to test the --nginx mode Runs le_test_nginx from acmetest against Pebble: nginx listens on Pebble's HTTP-01 validation port with an aaPanel/BT style "location ^~ /" reverse proxy block, the regression case of #6125. --- .github/workflows/Nginx.yml | 66 +++++++++++++++++++++++++++++++++++++ acme.sh | 11 +++++-- 2 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/Nginx.yml diff --git a/.github/workflows/Nginx.yml b/.github/workflows/Nginx.yml new file mode 100644 index 00000000..2ca9d64a --- /dev/null +++ b/.github/workflows/Nginx.yml @@ -0,0 +1,66 @@ +name: Nginx +on: + push: + paths: + - '*.sh' + - '.github/workflows/Nginx.yml' + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Nginx.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + Nginx: + runs-on: ubuntu-latest + env: + TestingDomain: example.com + TEST_ACME_Server: https://localhost:14000/dir + HTTPS_INSECURE: 1 + TEST_LOCAL: 1 + TEST_CA: "Pebble Intermediate CA" + TEST_NGINX: 1 + CASE: le_test_nginx + steps: + - uses: actions/checkout@v6 + - name: Install tools + run: sudo apt-get install -y socat nginx + - name: Run Pebble + run: cd .. && curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml && docker compose up -d + - name: Set up Pebble + run: curl --request POST --data '{"ip":"10.30.50.1"}' http://localhost:8055/set-default-ipv4 + - name: Set up nginx + # a backend on 8081 plus a site with an aaPanel/BT style + # "location ^~ /" proxy block that shadows plain regex locations + # (regression for #6125); the site listens on 5002, which is the + # HTTP-01 validation port in Pebble's default config + run: | + sudo tee /etc/nginx/sites-available/default >/dev/null <<'EOF' + server { + listen 127.0.0.1:8081; + location / { + default_type text/plain; + return 200 "backend"; + } + } + server { + listen 5002 default_server; + server_name example.com; + location ^~ / { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $http_host; + } + } + EOF + sudo nginx -t + sudo systemctl restart nginx + curl -s -H "Host: example.com" http://127.0.0.1:5002/ | grep backend + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - name: Run acmetest + run: cd ../acmetest && sudo --preserve-env ./letest.sh diff --git a/acme.sh b/acme.sh index 98f237a7..1b9c0b89 100755 --- a/acme.sh +++ b/acme.sh @@ -3448,9 +3448,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" From 504540e67ccf564dcdfd8cd369a26ff12127748e Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:29:58 +0800 Subject: [PATCH 110/224] dnsapi/dns_autodns: escape XML special characters in credentials (#5317) --- dnsapi/dns_autodns.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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" +} + _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: From 507baff2ef8b4391c32b68d4b818f8fe439214dc Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:30:46 +0800 Subject: [PATCH 111/224] deploy/docker: allow setting key file mode and owner in the container The docker deploy hook copied the key file preserving the source mode (root:root 0600), so a non-root container service (uid >= 1000) could not read it. Add DEPLOY_DOCKER_CONTAINER_KEY_MODE and DEPLOY_DOCKER_CONTAINER_KEY_OWNER, applied via chmod/chown inside the container after the key is copied and before the reload command. Closes #5333 --- deploy/docker.sh | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/deploy/docker.sh b/deploy/docker.sh index 7fdcf604..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 From 1f94fd7fd596f0bee2e11fbfd033c43a0eeb3d78 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:31:51 +0800 Subject: [PATCH 112/224] add Apache workflow to test the --apache mode Runs le_test_apache from acmetest against Pebble, with Apache listening on Pebble's HTTP-01 validation port. --- .github/workflows/Apache.yml | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/Apache.yml diff --git a/.github/workflows/Apache.yml b/.github/workflows/Apache.yml new file mode 100644 index 00000000..b17abbd1 --- /dev/null +++ b/.github/workflows/Apache.yml @@ -0,0 +1,50 @@ +name: Apache +on: + push: + paths: + - '*.sh' + - '.github/workflows/Apache.yml' + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Apache.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + Apache: + runs-on: ubuntu-latest + env: + TestingDomain: example.com + TEST_ACME_Server: https://localhost:14000/dir + HTTPS_INSECURE: 1 + TEST_LOCAL: 1 + TEST_CA: "Pebble Intermediate CA" + TEST_APACHE: 1 + CASE: le_test_apache + steps: + - uses: actions/checkout@v6 + - name: Install tools + run: sudo apt-get install -y socat apache2 + - name: Run Pebble + run: cd .. && curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml && docker compose up -d + - name: Set up Pebble + run: curl --request POST --data '{"ip":"10.30.50.1"}' http://localhost:8055/set-default-ipv4 + - name: Set up Apache + # Apache serves on 5002, which is the HTTP-01 validation port in + # Pebble's default config; acme.sh appends the challenge Alias to + # the main config itself + run: | + echo "Listen 5002" | sudo tee /etc/apache2/ports.conf + sudo sed -i "s/\*:80/*:5002/" /etc/apache2/sites-available/000-default.conf + sudo apache2ctl configtest + sudo systemctl restart apache2 + curl -s -o /dev/null -w "%{http_code}" -H "Host: example.com" http://127.0.0.1:5002/ | grep -E "200|403|404" + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - name: Run acmetest + run: cd ../acmetest && sudo --preserve-env ./letest.sh From 4256e3532b37b5ff27ad5a534e50aef01003c82a Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:32:57 +0800 Subject: [PATCH 113/224] _regAccount: error out clearly when the eab-hmac-key cannot be base64-decoded An undecodable key (e.g. broken LibreSSL base64 -d -A) used to produce the cryptic "Usage: _hmac hashalg secret [outputhex]" and an empty EAB signature that the CA rejects with 403. https://github.com/acmesh-official/acme.sh/issues/4082 --- acme.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/acme.sh b/acme.sh index 1b9c0b89..42d94f79 100755 --- a/acme.sh +++ b/acme.sh @@ -4014,6 +4014,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" From defd64022d8864153d8a6caeb18c76066be3dca3 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:53:14 +0800 Subject: [PATCH 114/224] dnsapi/dns_namecom: probe the root zone with GetDomain instead of listing all domains The domain list is paginated at 1000 entries per page and only the first page was fetched, so accounts with more than 1000 domains never found the root zone. fix https://github.com/acmesh-official/acme.sh/issues/5051 --- dnsapi/dns_namecom.sh | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/dnsapi/dns_namecom.sh b/dnsapi/dns_namecom.sh index 1062c849..1ba6a6e5 100755 --- a/dnsapi/dns_namecom.sh +++ b/dnsapi/dns_namecom.sh @@ -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 From 7def43481a41f35c30e45b5407878145e8ffa53c Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:57:59 +0800 Subject: [PATCH 115/224] dns_regru: require a dot boundary in root zone matching _get_root matched a registered domain anywhere as a substring of the challenge domain, so with both "test.com.ru" and "subtest.com.ru" in the account, issuing for subtest.com.ru wrongly picked test.com.ru as the root (it is a substring of "sub-test.com.ru"). Anchor the match to a '.' boundary so a shorter domain no longer matches a longer subdomain label. Fixes the issue reported in #5036 (thanks @koledas) Closes #5036 --- dnsapi/dns_regru.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 934711e51d10c765f67900892e2759ae3dd37dae Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 17:09:51 +0800 Subject: [PATCH 116/224] notify/aws_ses: add container/instance IAM role auth (IMDSv2) aws_ses_send calls `_use_container_role || _use_instance_role` when no static AWS keys are set, but those functions were never defined -- only _use_metadata was -- so role-based auth silently fell through to the "no api key" error. Add both, using the current IMDSv2-capable versions from dns_aws.sh, and set the IMDSv2 token header in _use_metadata so the credential fetch works on IMDSv2-only instances. Closes #4742 --- notify/aws_ses.sh | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) 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 | From e964157bffedb865b532e24fcf8abe6b1e0fe1ab Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 17:13:24 +0800 Subject: [PATCH 117/224] _install_win_taskscheduler: zero-pad the minute in the schtasks /ST value (#4950) --- acme.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index 42d94f79..d3e364bb 100755 --- a/acme.sh +++ b/acme.sh @@ -6658,8 +6658,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 } From d2b3772631f7055f27a267ae94b83e0f4c99962f Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 17:13:59 +0800 Subject: [PATCH 118/224] deploy/panos: do not commit when the cert or key import failed (#4716) Committing after a failed import leaves a mismatched cert/key pair on the firewall (PAN-OS does not validate the pair at commit time), which can lock the admin out of the https management interface. --- deploy/panos.sh | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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 From 1746fbdb255204bbb417e1ca3298699863630eb0 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 17:37:40 +0800 Subject: [PATCH 119/224] support multiple account emails (#1309) ACCOUNT_EMAIL / --email now accepts a comma- or space-separated list and registers all of them as ACME contact entries. The ZeroSSL EAB endpoint takes a single address, so the first one is used there. --- acme.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index d3e364bb..931ab750 100755 --- a/acme.sh +++ b/acme.sh @@ -3917,6 +3917,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" @@ -3976,7 +3986,9 @@ _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." @@ -4026,7 +4038,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}" @@ -4106,7 +4118,7 @@ updateaccount() { _email="$(_getAccountEmail)" if [ "$_email" ]; then - updjson='{"contact": ["mailto:'$_email'"]}' + updjson='{"contact": ['$(echo "$_email" | _mailto_contacts)']}' else updjson='{"contact": []}' fi From cabe432539f0acbdac954c48175d20d259ab9aaf Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 17:39:50 +0800 Subject: [PATCH 120/224] add bash completion for commands and parameters, installed via --install (#307) --- acme.sh | 11 ++ acme.sh.completion | 341 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 acme.sh.completion diff --git a/acme.sh b/acme.sh index 931ab750..4735b830 100755 --- a/acme.sh +++ b/acme.sh @@ -7236,6 +7236,10 @@ _installalias() { _sed_i "/^export LE_CONFIG_HOME/d" "$_envfile" fi _setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_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 @@ -7353,6 +7357,11 @@ install() { _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 + if [ "$_ACME_IN_CRON" != "1" ] && [ -z "$_noprofile" ]; then _installalias "$_c_home" fi @@ -7430,6 +7439,7 @@ uninstall() { _uninstallalias rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY" + rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" _info "The keys and certs are in \"$(__green "$LE_CONFIG_HOME")\". You can remove them by yourself." } @@ -7739,6 +7749,7 @@ 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. Negative values could be used to specify a number of days relative to the expiration date of the certificate. 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 From 7b6d96387c7eae778ab3e630950d14f8a434785e Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 17:48:33 +0800 Subject: [PATCH 121/224] migrate the legacy ACMEDNS_UPDATE_URL from the account conf (#3899) --- dnsapi/dns_acmedns.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dnsapi/dns_acmedns.sh b/dnsapi/dns_acmedns.sh index b109a4e5..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 From d3af3315da2a8d9a0080bc62c348e728db7feefc Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 18:02:34 +0800 Subject: [PATCH 122/224] dnsapi/dns_edgedns: use the system clock for the request timestamp (#3973) --- dnsapi/dns_edgedns.sh | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) 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" } From 2a175f97e87890e47f87eb09198155cc5a94a668 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 18:04:24 +0800 Subject: [PATCH 123/224] toPkcs8: support --password and re-export the pkcs8 file on renewal (#4134) --- acme.sh | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/acme.sh b/acme.sh index 4735b830..f09436b9 100755 --- a/acme.sh +++ b/acme.sh @@ -1547,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" @@ -1568,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" @@ -5889,6 +5906,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" @@ -7801,7 +7824,7 @@ 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'. " @@ -8818,7 +8841,7 @@ _process() { toPkcs "$_domain" "$_password" "$_ecc" ;; toPkcs8) - toPkcs8 "$_domain" "$_ecc" + toPkcs8 "$_domain" "$_password" "$_ecc" ;; createAccountKey) createAccountKey "$_accountkeylength" From 8f3c1701f396887bf242e912dcbe3a50419af8c1 Mon Sep 17 00:00:00 2001 From: LaoDC Date: Sun, 5 Jul 2026 22:30:26 +0700 Subject: [PATCH 124/224] Add LaoDC DNS API (dns_laodc) (#6974) * Added LaoDC API Module * Cleaned up debug and info revised get subdomain to filter by TXT records. * Added commet to _get_root * Removed PATCH logic of updating acme records as this doesn't work for wildcard DNS. Revised rm() function to do explicit record matching. * Revised _get_root() to handle different scenarios. Fixed _laodc_api() function to check if query failed to run. added basic json sanitation to handle \ and " in $value unset _H2 _H3 after call as per request from copilot. * fixed indentation of case statement block * fixed condition checking. _get_root should start at 1 so full fqdn can be tested $? was being reference after export command failing response checks removed export txtvalue fixed docs link and issues link * Verify key for both add and rm * fixed dns alias condition check validate key returns 1 if failed. --------- Co-authored-by: neil Co-authored-by: LaoDC --- dnsapi/dns_laodc.sh | 197 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 dnsapi/dns_laodc.sh 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 +} From 58423df3e82b181946c1d57f821f20405715c52d Mon Sep 17 00:00:00 2001 From: PM Extra Date: Sun, 5 Jul 2026 23:33:57 +0800 Subject: [PATCH 125/224] retry failed install and deploy on renew (#7083) * retry failed install and deploy on renew * fix notify level for renew retry failures --- acme.sh | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/acme.sh b/acme.sh index f09436b9..5b8f9566 100755 --- a/acme.sh +++ b/acme.sh @@ -6052,6 +6052,31 @@ renew() { 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 @@ -6485,6 +6510,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 @@ -6644,9 +6708,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() { From 1f778e6ef1c08d089413da8777359ef9ec87f9d3 Mon Sep 17 00:00:00 2001 From: Oliver Mueller Date: Mon, 6 Jul 2026 04:11:58 +0200 Subject: [PATCH 126/224] deploy/ssh: return non-zero when a server deployment fails (#6795) ssh_deploy() ignored the result of _ssh_deploy and always returned success, so a failed transfer to one (or all) of the servers in DEPLOY_SSH_SERVER was silently swallowed. Track the return code across the loop and return non-zero if any server failed, letting the caller handle notification. Co-authored-by: Claude Opus 4.8 (1M context) --- deploy/ssh.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/deploy/ssh.sh b/deploy/ssh.sh index 848380a5..82b0382c 100644 --- a/deploy/ssh.sh +++ b/deploy/ssh.sh @@ -170,10 +170,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() { From ff9b969bdb04065f0ae6afae0449bbe5f02d3d9d Mon Sep 17 00:00:00 2001 From: "Simon V." <218359733+sim0n-v@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:22:36 +0200 Subject: [PATCH 127/224] Add support for Account Key Rollover (#7080) * add wiki * feat: add support for account key rollover * Place --update-account-key next to --update-account * fix shfmt * fix shfmt * fix shfmt * Fix from review * fix shfmt * fix from review * fix review --------- Co-authored-by: neil --- acme.sh | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/acme.sh b/acme.sh index 5b8f9566..4f1c0336 100755 --- a/acme.sh +++ b/acme.sh @@ -4154,6 +4154,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 @@ -7786,6 +7873,7 @@ 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 @@ -8338,6 +8426,9 @@ _process() { --update-account | --updateaccount) _CMD="updateaccount" ;; + --update-account-key | --updateaccountkey) + _CMD="updateaccountkey" + ;; --register-account | --registeraccount) _CMD="registeraccount" ;; @@ -8931,6 +9022,9 @@ _process() { updateaccount) updateaccount ;; + updateaccountkey) + updateaccountkey "$_accountkeylength" + ;; deactivateaccount) deactivateaccount ;; From ca8ab7f8b5bdaa43b2ecb1cf4bc7d4af6bba1743 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 6 Jul 2026 13:35:27 +0800 Subject: [PATCH 128/224] add wiki-guard workflow: auto-restore wiki pages deleted or renamed by non-maintainer --- .github/workflows/wiki-guard.yml | 95 ++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/wiki-guard.yml diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml new file mode 100644 index 00000000..356ff58a --- /dev/null +++ b/.github/workflows/wiki-guard.yml @@ -0,0 +1,95 @@ +name: Restore Wiki Pages Deleted by Others + +# The gollum event only fires on page create/update, never on deletion, +# so deletions can only be caught by polling the wiki git history. + +on: + schedule: + - cron: "*/10 * * * *" + gollum: + workflow_dispatch: + +permissions: + contents: write + issues: write + +concurrency: + group: wiki-guard + cancel-in-progress: false + +jobs: + restore: + runs-on: ubuntu-latest + steps: + - name: Checkout wiki repository + uses: actions/checkout@v7 + with: + repository: ${{ github.repository }}.wiki + path: wiki + fetch-depth: 0 + + - name: Restore pages deleted by non-maintainer + id: restore + run: | + cd wiki + git config core.quotePath false + + # Any author email under this domain is the maintainer and may delete pages. + OWNER_DOMAIN="neilpang.com" + + : > ../restored.txt + + # Paths deleted within the recent window (rolling; the cron runs + # every 10 minutes, so 7 days gives ample overlap without + # resurrecting old deletions the maintainer already accepted). + # --no-renames makes a rename count as a deletion of the old path, + # so a page renamed by a non-maintainer is restored under its + # original name (the new name stays -- creating pages is allowed). + git log --since="7 days ago" --diff-filter=D --no-renames --name-only --format= \ + | sort -u \ + | while IFS= read -r f; do + if [ -z "$f" ] || [ -e "$f" ]; then + continue + fi + # The most recent commit (in full history) that deleted this path. + del="$(git log -1 --diff-filter=D --no-renames --format=%H -- "$f")" + if [ -z "$del" ]; then + continue + fi + ae="$(git show -s --format=%ae "$del" | tr 'A-Z' 'a-z')" + case "$ae" in + *@"$OWNER_DOMAIN") + continue + ;; + esac + an="$(git show -s --format=%an "$del")" + echo "Restoring: $f (deleted in $del by $an <$ae>)" + git checkout "$del^" -- "$f" + printf '%s\n' "- \`$f\` deleted in $del by $an <$ae>" >> ../restored.txt + done + + if [ -s ../restored.txt ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "Restore pages deleted by non-maintainer" + git push origin HEAD || { git pull --rebase && git push origin HEAD; } + { + echo "The following wiki pages were deleted or renamed by someone other than the maintainer and have been restored automatically:" + echo "" + cat ../restored.txt + echo "" + echo "Wiki: https://github.com/${GITHUB_REPOSITORY}/wiki" + } > ../restore-msg.txt + echo "restored=true" >> "$GITHUB_OUTPUT" + else + echo "No unauthorized deletions found." + echo "restored=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create issue to notify Neilpang + if: steps.restore.outputs.restored == 'true' + uses: peter-evans/create-issue-from-file@v6 + with: + title: "Wiki pages restored after unauthorized deletion" + content-filepath: ./restore-msg.txt + assignees: Neilpang From 8585d9f4a7bc4a135df9e6f995388fadf76b3622 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 6 Jul 2026 14:23:51 +0800 Subject: [PATCH 129/224] wiki-monitor: skip notification for the maintainer's own wiki changes --- .github/workflows/wiki-monitor.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wiki-monitor.yml b/.github/workflows/wiki-monitor.yml index 7e5d7ca3..349ef3f3 100644 --- a/.github/workflows/wiki-monitor.yml +++ b/.github/workflows/wiki-monitor.yml @@ -9,13 +9,14 @@ jobs: if: github.actor != 'neilpang' steps: - name: Checkout wiki repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: ${{ github.repository }}.wiki path: wiki fetch-depth: 0 - name: Generate wiki change message + id: msg run: | actor="${{ github.actor }}" sender_url=$(jq -r '.sender.html_url' "$GITHUB_EVENT_PATH") @@ -27,6 +28,17 @@ jobs: now="$(date '+%Y-%m-%d %H:%M:%S')" cd wiki + # Skip notification when the change was authored by the + # maintainer himself (any author email under neilpang.com), + # e.g. a direct git push to the wiki repository. + author_email=$(git show -s --format=%ae "$page_sha" 2>/dev/null | tr 'A-Z' 'a-z') + case "$author_email" in + *@neilpang.com) + echo "Change authored by maintainer ($author_email); skipping notification." + echo "notify=false" >> "$GITHUB_OUTPUT" + exit 0 + ;; + esac prev_sha=$(git rev-list $page_sha^ -- "$page_name.md" | head -n 1) if [ -n "$prev_sha" ]; then git diff $prev_sha $page_sha -- "$page_name.md" > ../wiki.diff || echo "(No diff found)" > ../wiki.diff @@ -44,13 +56,15 @@ jobs: echo "[Click here to Revert](${page_url}/_history)" echo "" echo "----" - echo "### diff:" + echo "### diff:" echo '```diff' cat wiki.diff echo '```' } > wiki-change-msg.txt + echo "notify=true" >> "$GITHUB_OUTPUT" - name: Create issue to notify Neilpang + if: steps.msg.outputs.notify == 'true' uses: peter-evans/create-issue-from-file@v6 with: title: "Wiki edited" From dc1b06006f244f75b4ab9f446191f4b8df3139dc Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 6 Jul 2026 14:27:00 +0800 Subject: [PATCH 130/224] issue.yml: assign and label "Report bugs to" tracking issues instead of posting the upgrade boilerplate --- .github/workflows/issue.yml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index c659fce5..37e95eba 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -10,10 +10,27 @@ jobs: - uses: actions/github-script@v9 with: script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, + const issue = context.payload.issue; + if (issue.title.startsWith("Report bugs to")) { + // Tracking issue for a third-party dns/deploy/notify api: + // no upgrade boilerplate; assign it to the opener and label it. + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + assignees: [issue.user.login] + }); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ["3rd party api"] + }); + return; + } + await github.rest.issues.createComment({ + issue_number: issue.number, owner: context.repo.owner, repo: context.repo.repo, body: "Please upgrade to the latest code and try again first. Maybe it's already fixed. ```acme.sh --upgrade``` If it's still not working, please provide the log with `--debug 2`, otherwise, nobody can help you." - }) \ No newline at end of file From 2b5a19d34a363597be753261f43b584448599df8 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 6 Jul 2026 15:11:35 +0800 Subject: [PATCH 131/224] wiki-guard: trust repo/org members with write access in all rule checks --- .github/workflows/issue.yml | 46 +++++ .github/workflows/wiki-guard.yml | 293 +++++++++++++++++++++++++++---- 2 files changed, 301 insertions(+), 38 deletions(-) diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index 37e95eba..bd6dc9e8 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -2,6 +2,12 @@ name: "Update issues" on: issues: types: [opened] + pull_request_target: + types: [opened] + +permissions: + issues: write + pull-requests: write jobs: comment: @@ -10,6 +16,46 @@ jobs: - uses: actions/github-script@v9 with: script: | + const item = context.payload.issue || context.payload.pull_request; + + // Close on sight anything opened by a user on the wiki Blacklist + // page (maintained by the Wiki Guard workflow). + let blacklist = []; + try { + const res = await fetch(`https://raw.githubusercontent.com/wiki/${context.repo.owner}/${context.repo.repo}/Blacklist.md`); + if (res.ok) { + blacklist = (await res.text()).split("\n") + .filter(l => l.startsWith("- ")) + .map(l => l.slice(2).trim().toLowerCase()) + .filter(Boolean); + } + } catch (e) { + core.warning(`Failed to fetch the blacklist: ${e}`); + } + if (blacklist.includes(item.user.login.toLowerCase())) { + if (context.payload.pull_request) { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: item.number, + state: "closed" + }); + } else { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + state: "closed", + state_reason: "not_planned" + }); + } + return; + } + + if (context.payload.pull_request) { + return; + } + const issue = context.payload.issue; if (issue.title.startsWith("Report bugs to")) { // Tracking issue for a third-party dns/deploy/notify api: diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml index 356ff58a..a0199441 100644 --- a/.github/workflows/wiki-guard.yml +++ b/.github/workflows/wiki-guard.yml @@ -1,7 +1,19 @@ -name: Restore Wiki Pages Deleted by Others +name: Wiki Guard +# Rules enforced here: +# - Only the maintainer and write-access members may delete or rename wiki +# pages. Anyone else doing so gets blacklisted and the page restored to +# its last good revision. +# - Only the maintainer and write-access members may edit the Blacklist +# wiki page. Anyone else touching it gets blacklisted and the page +# reverted. +# - Any wiki change made by a blacklisted identity is reverted. +# A "good" revision is one authored by the maintainer, by this bot, or by +# a non-blacklisted user -- restoring from the deleted commit's parent is +# NOT safe, because vandals replace a page before destroying it and the +# parent would launder their version into a bot commit. # The gollum event only fires on page create/update, never on deletion, -# so deletions can only be caught by polling the wiki git history. +# so violations are caught by polling the wiki git history. on: schedule: @@ -18,7 +30,7 @@ concurrency: cancel-in-progress: false jobs: - restore: + guard: runs-on: ubuntu-latest steps: - name: Checkout wiki repository @@ -28,68 +40,273 @@ jobs: path: wiki fetch-depth: 0 - - name: Restore pages deleted by non-maintainer - id: restore + - name: Enforce wiki rules + id: guard + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + # Logins with write (push) access to the repository, including + # organization members -- they may delete/rename pages and edit + # the blacklist just like the maintainer. If the API call fails, + # the list stays empty and enforcement falls back to + # maintainer-only, which is the safe direction. + gh api "repos/${GITHUB_REPOSITORY}/collaborators?per_page=100" --paginate \ + -q '.[] | select(.permissions.push) | .login' 2>/dev/null \ + | tr 'A-Z' 'a-z' | sort -u > writers.txt || true cd wiki git config core.quotePath false - # Any author email under this domain is the maintainer and may delete pages. + # Any author email under this domain is the maintainer. OWNER_DOMAIN="neilpang.com" + # Our own enforcement commits. + BOT_EMAIL="41898282+github-actions[bot]@users.noreply.github.com" + BL_PAGE="Blacklist.md" + # Rolling window; the cron runs every 10 minutes, so 7 days gives + # ample overlap without re-judging old changes the maintainer + # already accepted. + WINDOW="7 days ago" - : > ../restored.txt + : > ../actions.txt + : > ../bl_new.txt - # Paths deleted within the recent window (rolling; the cron runs - # every 10 minutes, so 7 days gives ample overlap without - # resurrecting old deletions the maintainer already accepted). - # --no-renames makes a rename count as a deletion of the old path, - # so a page renamed by a non-maintainer is restored under its - # original name (the new name stays -- creating pages is allowed). - git log --since="7 days ago" --diff-filter=D --no-renames --name-only --format= \ + is_owner() { + case "$1" in + *@"$OWNER_DOMAIN") return 0 ;; + esac + return 1 + } + + is_bot() { + [ "$1" = "$BOT_EMAIL" ] + } + + author_email() { + git show -s --format=%ae "$1" | tr 'A-Z' 'a-z' + } + + # Identity of a commit author: the GitHub login when the email is a + # users.noreply.github.com address, otherwise the email itself. + identity_of() { + case "$1" in + *+*@users.noreply.github.com) + printf '%s\n' "$1" | sed 's/^[^+]*+//; s/@users\.noreply\.github\.com$//' + ;; + *@users.noreply.github.com) + printf '%s\n' "$1" | sed 's/@users\.noreply\.github\.com$//' + ;; + *) + printf '%s\n' "$1" + ;; + esac + } + + is_blacklisted() { + grep -Fxq "$1" ../bl_all.txt + } + + # Trusted committers: the maintainer (by email domain), this bot, + # and anyone whose GitHub login has write access to the repo. + is_trusted() { + if is_owner "$1" || is_bot "$1"; then + return 0 + fi + grep -Fxq "$(identity_of "$1")" ../writers.txt + } + + # Newest commit on file $1 authored by a non-blacklisted user. + last_good_for() { + for g in $(git log --format=%H --no-renames -- "$1"); do + gae="$(author_email "$g")" + if is_trusted "$gae"; then + printf '%s\n' "$g" + return 0 + fi + gid="$(identity_of "$gae")" + if ! is_blacklisted "$gid" && ! is_blacklisted "$gae"; then + printf '%s\n' "$g" + return 0 + fi + done + return 0 + } + + if [ -e "$BL_PAGE" ]; then + page_existed=1 + else + page_existed="" + fi + + # ---- 1. Last good version of the blacklist page: the newest + # revision authored by the maintainer or by this bot. Everything + # else on that page is tampering and is discarded. + bl_good_commit="" + for c in $(git log --format=%H --no-renames -- "$BL_PAGE"); do + ae="$(author_email "$c")" + if is_trusted "$ae"; then + bl_good_commit="$c" + break + fi + done + if [ -n "$bl_good_commit" ] && git cat-file -e "$bl_good_commit:$BL_PAGE" 2>/dev/null; then + git show "$bl_good_commit:$BL_PAGE" > ../bl_page.txt + else + { + echo "# Blacklist" + echo "" + echo "Users listed below violated the wiki rules (deleted or renamed" + echo "pages, or tampered with this page). Their new issues and pull" + echo "requests are closed on sight and their wiki edits are reverted" + echo "automatically. Only the maintainer and write-access members" + echo "may edit this page." + echo "" + echo "To pardon a user while their violation is still inside the" + echo "scan window, replace their entry with: pardon: username" + echo "" + } > ../bl_page.txt + fi + sed -n 's/^- *//p' ../bl_page.txt | tr -d '\r' | tr 'A-Z' 'a-z' | sort -u > ../bl_good.txt + sed -n 's/^[Pp]ardon: *//p' ../bl_page.txt | tr -d '\r' | tr 'A-Z' 'a-z' | sort -u > ../bl_pardon.txt + + bl_add() { + if grep -Fxq "$1" ../bl_pardon.txt; then + return 0 + fi + if ! grep -Fxq "$1" ../bl_good.txt && ! grep -Fxq "$1" ../bl_new.txt; then + printf '%s\n' "$1" >> ../bl_new.txt + printf '%s\n' "- blacklisted \`$1\`: $2" >> ../actions.txt + fi + } + + # ---- 2. Blacklist everyone who deleted or renamed a page. + # --no-renames makes a rename count as a deletion of the old path. + for c in $(git log --since="$WINDOW" --diff-filter=D --no-renames --format=%H); do + ae="$(author_email "$c")" + if is_trusted "$ae"; then + continue + fi + an="$(git show -s --format=%an "$c")" + bl_add "$(identity_of "$ae")" "deleted or renamed pages in $c ($an <$ae>)" + done + + # ---- 3. Blacklist everyone else who touched the blacklist page. + # The revert of their tampering falls out of steps 5 and 6. + for c in $(git log --since="$WINDOW" --format=%H --no-renames -- "$BL_PAGE"); do + ae="$(author_email "$c")" + if is_trusted "$ae"; then + continue + fi + an="$(git show -s --format=%an "$c")" + bl_add "$(identity_of "$ae")" "tampered with \`$BL_PAGE\` in $c ($an <$ae>)" + done + + sort -u ../bl_new.txt > ../bl_new_u.txt + cat ../bl_good.txt ../bl_new_u.txt | sort -u > ../bl_all.txt + + # ---- 4. Restore pages that are currently missing because a + # non-maintainer deleted them, using the last good revision. + git log --since="$WINDOW" --diff-filter=D --no-renames --name-only --format= \ | sort -u \ | while IFS= read -r f; do - if [ -z "$f" ] || [ -e "$f" ]; then + if [ -z "$f" ] || [ "$f" = "$BL_PAGE" ] || [ -e "$f" ]; then continue fi - # The most recent commit (in full history) that deleted this path. del="$(git log -1 --diff-filter=D --no-renames --format=%H -- "$f")" if [ -z "$del" ]; then continue fi - ae="$(git show -s --format=%ae "$del" | tr 'A-Z' 'a-z')" - case "$ae" in - *@"$OWNER_DOMAIN") - continue - ;; - esac - an="$(git show -s --format=%an "$del")" - echo "Restoring: $f (deleted in $del by $an <$ae>)" - git checkout "$del^" -- "$f" - printf '%s\n' "- \`$f\` deleted in $del by $an <$ae>" >> ../restored.txt + ae="$(author_email "$del")" + if is_trusted "$ae"; then + continue + fi + good="$(last_good_for "$f")" + if [ -n "$good" ] && git cat-file -e "$good:$f" 2>/dev/null; then + git checkout "$good" -- "$f" + printf '%s\n' "- restored \`$f\` (deleted in $del) from its last good revision $good" >> ../actions.txt + fi done - if [ -s ../restored.txt ]; then + # ---- 5. Revert every recent change made by a blacklisted + # identity: each touched file goes back to its newest revision + # authored by a non-blacklisted user; a file that has no such + # revision (they created it) is removed. + if [ -s ../bl_all.txt ]; then + for c in $(git log --since="$WINDOW" --format=%H --no-renames); do + ae="$(author_email "$c")" + if is_trusted "$ae"; then + continue + fi + id="$(identity_of "$ae")" + if ! is_blacklisted "$id" && ! is_blacklisted "$ae"; then + continue + fi + git show --name-only --no-renames --format= "$c" \ + | while IFS= read -r f; do + if [ -z "$f" ] || [ "$f" = "$BL_PAGE" ]; then + continue + fi + good="$(last_good_for "$f")" + if [ -n "$good" ] && git cat-file -e "$good:$f" 2>/dev/null; then + want="$(git rev-parse "$good:$f")" + have="$(git hash-object -- "$f" 2>/dev/null || echo missing)" + if [ "$want" != "$have" ]; then + git checkout "$good" -- "$f" + printf '%s\n' "- reverted \`$f\` to its last good revision $good (undoing change by \`$id\` in $c)" >> ../actions.txt + fi + elif [ -e "$f" ]; then + git rm -q -- "$f" + printf '%s\n' "- removed \`$f\` created by blacklisted \`$id\` in $c" >> ../actions.txt + fi + done + done + fi + + # ---- 6. Regenerate the blacklist page: the last good text plus + # any newly blacklisted identities. This both reverts tampering + # and records new violators; manual edits by the maintainer are + # preserved as the new good text. + cp ../bl_page.txt ../bl_page_new.txt + if [ -s ../bl_page_new.txt ] && [ -n "$(tail -c1 ../bl_page_new.txt)" ]; then + echo >> ../bl_page_new.txt + fi + while IFS= read -r id; do + if [ -n "$id" ] && ! grep -Fxiq -- "- $id" ../bl_page_new.txt; then + printf -- '- %s\n' "$id" >> ../bl_page_new.txt + fi + done < ../bl_new_u.txt + if ! cmp -s ../bl_page_new.txt "$BL_PAGE" 2>/dev/null; then + cp ../bl_page_new.txt "$BL_PAGE" + git add -- "$BL_PAGE" + if [ -n "$page_existed" ] || [ -s ../bl_new_u.txt ]; then + printf '%s\n' "- updated \`$BL_PAGE\`" >> ../actions.txt + fi + fi + + # ---- 7. Commit, push, notify. + if [ -n "$(git status --porcelain)" ]; then git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "Restore pages deleted by non-maintainer" + git config user.email "$BOT_EMAIL" + git commit -m "wiki-guard: restore pages and enforce blacklist" git push origin HEAD || { git pull --rebase && git push origin HEAD; } + fi + if [ -s ../actions.txt ]; then { - echo "The following wiki pages were deleted or renamed by someone other than the maintainer and have been restored automatically:" + echo "The wiki guard handled the following rule violations:" echo "" - cat ../restored.txt + cat ../actions.txt echo "" + echo "Blacklist: https://github.com/${GITHUB_REPOSITORY}/wiki/Blacklist" echo "Wiki: https://github.com/${GITHUB_REPOSITORY}/wiki" - } > ../restore-msg.txt - echo "restored=true" >> "$GITHUB_OUTPUT" + } > ../guard-msg.txt + echo "acted=true" >> "$GITHUB_OUTPUT" else - echo "No unauthorized deletions found." - echo "restored=false" >> "$GITHUB_OUTPUT" + echo "No rule violations found." + echo "acted=false" >> "$GITHUB_OUTPUT" fi - name: Create issue to notify Neilpang - if: steps.restore.outputs.restored == 'true' + if: steps.guard.outputs.acted == 'true' uses: peter-evans/create-issue-from-file@v6 with: - title: "Wiki pages restored after unauthorized deletion" - content-filepath: ./restore-msg.txt + title: "Wiki guard: rule violations handled" + content-filepath: ./guard-msg.txt assignees: Neilpang From e4eaa590639d8590f0fe3016f67bf7f85dbc511e Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 6 Jul 2026 15:23:45 +0800 Subject: [PATCH 132/224] wiki-guard: log the number of write-access members loaded --- .github/workflows/wiki-guard.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml index a0199441..45033554 100644 --- a/.github/workflows/wiki-guard.yml +++ b/.github/workflows/wiki-guard.yml @@ -53,6 +53,7 @@ jobs: gh api "repos/${GITHUB_REPOSITORY}/collaborators?per_page=100" --paginate \ -q '.[] | select(.permissions.push) | .login' 2>/dev/null \ | tr 'A-Z' 'a-z' | sort -u > writers.txt || true + echo "write-access members loaded: $(wc -l < writers.txt)" cd wiki git config core.quotePath false From 919492df1347127f14f92e4732d9b0d862a1a0ba Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 6 Jul 2026 16:27:26 +0800 Subject: [PATCH 133/224] wiki-guard: use WIKI_GUARD_TOKEN (PAT with read:org) to enumerate org write members --- .github/workflows/wiki-guard.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml index 45033554..6c392d37 100644 --- a/.github/workflows/wiki-guard.yml +++ b/.github/workflows/wiki-guard.yml @@ -43,7 +43,10 @@ jobs: - name: Enforce wiki rules id: guard env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # WIKI_GUARD_TOKEN: a PAT with read:org, needed to enumerate + # members whose write access comes via the organization -- the + # repo-scoped GITHUB_TOKEN only sees direct collaborators. + GH_TOKEN: ${{ secrets.WIKI_GUARD_TOKEN || secrets.GITHUB_TOKEN }} run: | # Logins with write (push) access to the repository, including # organization members -- they may delete/rename pages and edit @@ -54,6 +57,7 @@ jobs: -q '.[] | select(.permissions.push) | .login' 2>/dev/null \ | tr 'A-Z' 'a-z' | sort -u > writers.txt || true echo "write-access members loaded: $(wc -l < writers.txt)" + cat writers.txt cd wiki git config core.quotePath false From 73df21abc4d81e2e98fd5d67a8284bbee54368fc Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 6 Jul 2026 16:30:41 +0800 Subject: [PATCH 134/224] clean --- .github/workflows/wiki-guard.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml index 6c392d37..3c21f492 100644 --- a/.github/workflows/wiki-guard.yml +++ b/.github/workflows/wiki-guard.yml @@ -57,7 +57,6 @@ jobs: -q '.[] | select(.permissions.push) | .login' 2>/dev/null \ | tr 'A-Z' 'a-z' | sort -u > writers.txt || true echo "write-access members loaded: $(wc -l < writers.txt)" - cat writers.txt cd wiki git config core.quotePath false From f2b37b32ff6feec7228fbfbbe988056e6f26dac2 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 6 Jul 2026 16:36:16 +0800 Subject: [PATCH 135/224] add more events --- .github/workflows/wiki-guard.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml index 3c21f492..709fb8ea 100644 --- a/.github/workflows/wiki-guard.yml +++ b/.github/workflows/wiki-guard.yml @@ -19,6 +19,12 @@ on: schedule: - cron: "*/10 * * * *" gollum: + # Piggyback on frequent repo activity, because the cron schedule is + # best-effort and often delayed well beyond its interval. + issues: + types: [opened] + issue_comment: + types: [created] workflow_dispatch: permissions: From cc64a732308bb0f5cf5c573a0539f3db1ba4f4b0 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 6 Jul 2026 17:39:56 +0800 Subject: [PATCH 136/224] one-click revert and ban --- .github/workflows/blacklist-command.yml | 113 ++++++++++++++++++++++++ .github/workflows/issue.yml | 4 + .github/workflows/revert-command.yml | 109 +++++++++++++++++++++++ .github/workflows/wiki-monitor.yml | 4 +- 4 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/blacklist-command.yml create mode 100644 .github/workflows/revert-command.yml diff --git a/.github/workflows/blacklist-command.yml b/.github/workflows/blacklist-command.yml new file mode 100644 index 00000000..4d0ff5f3 --- /dev/null +++ b/.github/workflows/blacklist-command.yml @@ -0,0 +1,113 @@ +name: Blacklist Command + +# An issue titled "blacklist: " opened by the maintainer +# or a write-access member adds that identity to the Blacklist wiki page +# (see wiki-guard.yml) and closes the issue. The wiki-monitor notification +# embeds a prefilled link that opens such an issue in one click. + +on: + issues: + types: [opened] + +permissions: + contents: write + issues: write + +# Share the wiki-guard concurrency group so we never push to the wiki +# at the same time as the guard. +concurrency: + group: wiki-guard + cancel-in-progress: false + +jobs: + blacklist: + if: startsWith(github.event.issue.title, 'blacklist:') + runs-on: ubuntu-latest + steps: + - name: Check authorization + id: auth + run: | + assoc="${{ github.event.issue.author_association }}" + case "$assoc" in + OWNER|MEMBER|COLLABORATOR) + echo "ok=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "issue author is not authorized ($assoc); ignoring" + echo "ok=false" >> "$GITHUB_OUTPUT" + ;; + esac + + - name: Checkout wiki repository + if: steps.auth.outputs.ok == 'true' + uses: actions/checkout@v7 + with: + repository: ${{ github.repository }}.wiki + path: wiki + + - name: Add the identity to the blacklist page + if: steps.auth.outputs.ok == 'true' + id: add + env: + TITLE: ${{ github.event.issue.title }} + run: | + target="$(printf '%s' "$TITLE" \ + | sed 's/^blacklist:[[:space:]]*//; s/^@//; s/[[:space:]].*$//' \ + | tr 'A-Z' 'a-z')" + case "$target" in + ''|*[!a-z0-9._+@-]*) + echo "invalid target: '$target'" + echo "result=invalid" >> "$GITHUB_OUTPUT" + exit 0 + ;; + esac + echo "target=$target" >> "$GITHUB_OUTPUT" + cd wiki + if [ ! -e Blacklist.md ]; then + echo "result=nopage" >> "$GITHUB_OUTPUT" + exit 0 + fi + if grep -Fxiq -- "- $target" Blacklist.md; then + echo "result=already" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ -n "$(tail -c1 Blacklist.md)" ]; then + echo >> Blacklist.md + fi + printf -- '- %s\n' "$target" >> Blacklist.md + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Blacklist.md + git commit -m "blacklist $target (requested in #${{ github.event.issue.number }})" + git push origin HEAD || { git pull --rebase && git push origin HEAD; } + echo "result=added" >> "$GITHUB_OUTPUT" + + - name: Reply and close + if: steps.auth.outputs.ok == 'true' + uses: actions/github-script@v9 + env: + RESULT: ${{ steps.add.outputs.result }} + TARGET: ${{ steps.add.outputs.target }} + with: + script: | + const result = process.env.RESULT; + const target = process.env.TARGET; + const messages = { + added: `\`${target}\` has been added to the [Blacklist](https://github.com/${context.repo.owner}/${context.repo.repo}/wiki/Blacklist). The wiki guard will revert their recent wiki changes on its next run.`, + already: `\`${target}\` is already on the blacklist.`, + invalid: "Could not parse a valid login or email from the issue title.", + nopage: "The Blacklist wiki page does not exist." + }; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: messages[result] || "No action taken." + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: "closed", + state_reason: result === "added" ? "completed" : "not_planned" + }); diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index bd6dc9e8..a72c18ab 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -57,6 +57,10 @@ jobs: } const issue = context.payload.issue; + if (issue.title.startsWith("blacklist:") || issue.title.startsWith("revert:")) { + // Handled by the Blacklist / Revert Command workflows. + return; + } if (issue.title.startsWith("Report bugs to")) { // Tracking issue for a third-party dns/deploy/notify api: // no upgrade boilerplate; assign it to the opener and label it. diff --git a/.github/workflows/revert-command.yml b/.github/workflows/revert-command.yml new file mode 100644 index 00000000..7a792bf4 --- /dev/null +++ b/.github/workflows/revert-command.yml @@ -0,0 +1,109 @@ +name: Revert Command + +# An issue titled "revert: " opened by the maintainer or +# a write-access member reverts that commit in the wiki repository and +# closes the issue. The wiki-monitor notification embeds a prefilled link +# that opens such an issue in one click. + +on: + issues: + types: [opened] + +permissions: + contents: write + issues: write + +# Share the wiki-guard concurrency group so we never push to the wiki +# at the same time as the guard. +concurrency: + group: wiki-guard + cancel-in-progress: false + +jobs: + revert: + if: startsWith(github.event.issue.title, 'revert:') + runs-on: ubuntu-latest + steps: + - name: Check authorization + id: auth + run: | + assoc="${{ github.event.issue.author_association }}" + case "$assoc" in + OWNER|MEMBER|COLLABORATOR) + echo "ok=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "issue author is not authorized ($assoc); ignoring" + echo "ok=false" >> "$GITHUB_OUTPUT" + ;; + esac + + - name: Checkout wiki repository + if: steps.auth.outputs.ok == 'true' + uses: actions/checkout@v7 + with: + repository: ${{ github.repository }}.wiki + path: wiki + fetch-depth: 0 + + - name: Revert the wiki commit + if: steps.auth.outputs.ok == 'true' + id: revert + env: + TITLE: ${{ github.event.issue.title }} + run: | + target="$(printf '%s' "$TITLE" \ + | sed 's/^revert:[[:space:]]*//; s/[[:space:]].*$//' \ + | tr 'A-Z' 'a-z')" + case "$target" in + *[!0-9a-f]*|"") + echo "invalid commit sha: '$target'" + echo "result=invalid" >> "$GITHUB_OUTPUT" + exit 0 + ;; + esac + echo "target=$target" >> "$GITHUB_OUTPUT" + cd wiki + if ! git cat-file -e "$target^{commit}" 2>/dev/null; then + echo "result=notfound" >> "$GITHUB_OUTPUT" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git revert --no-edit "$target"; then + git push origin HEAD || { git pull --rebase && git push origin HEAD; } + echo "result=reverted" >> "$GITHUB_OUTPUT" + else + git revert --abort || true + echo "result=conflict" >> "$GITHUB_OUTPUT" + fi + + - name: Reply and close + if: steps.auth.outputs.ok == 'true' + uses: actions/github-script@v9 + env: + RESULT: ${{ steps.revert.outputs.result }} + TARGET: ${{ steps.revert.outputs.target }} + with: + script: | + const result = process.env.RESULT; + const target = process.env.TARGET; + const messages = { + reverted: `Wiki commit \`${target}\` has been reverted.`, + conflict: `Reverting \`${target}\` conflicts with later edits; please revert manually from the page history.`, + notfound: `Commit \`${target}\` was not found in the wiki repository.`, + invalid: "Could not parse a commit sha from the issue title." + }; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: messages[result] || "No action taken." + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: "closed", + state_reason: result === "reverted" ? "completed" : "not_planned" + }); diff --git a/.github/workflows/wiki-monitor.yml b/.github/workflows/wiki-monitor.yml index 349ef3f3..89bb1f3a 100644 --- a/.github/workflows/wiki-monitor.yml +++ b/.github/workflows/wiki-monitor.yml @@ -53,7 +53,9 @@ jobs: echo "Time: $now" echo "Page: [$page_name]($page_url) (Action: $page_action)" echo "Comment: $page_summary" - echo "[Click here to Revert](${page_url}/_history)" + echo "[Click here to Revert](https://github.com/${GITHUB_REPOSITORY}/issues/new?title=revert%3A+${page_sha}&body=Revert+wiki+commit+${page_sha}+by+@${actor}.)" + echo "" + echo "[Click here to Blacklist @$actor](https://github.com/${GITHUB_REPOSITORY}/issues/new?title=blacklist%3A+${actor}&body=Blacklist+@${actor},+requested+from+the+wiki+monitor.)" echo "" echo "----" echo "### diff:" From b1b539695f8e6337421bb65a5a76afb758c8c2ff Mon Sep 17 00:00:00 2001 From: Marvo2011 Date: Mon, 6 Jul 2026 15:30:13 +0200 Subject: [PATCH 137/224] Merge pull request #7026 from Marvo2011/dev Update SelfHost DNS provider --- dnsapi/dns_selfhost.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/dnsapi/dns_selfhost.sh b/dnsapi/dns_selfhost.sh index 40cc0210..782a5d5f 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://account.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 @@ -84,6 +87,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" From ca118be754d59ecdfcc5f298d156dcd7fa93a1c3 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 10 Jul 2026 10:02:45 +0800 Subject: [PATCH 138/224] issue.yml: match tracking issue title variants --- .github/workflows/issue.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index a72c18ab..f72a1f06 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -61,7 +61,7 @@ jobs: // Handled by the Blacklist / Revert Command workflows. return; } - if (issue.title.startsWith("Report bugs to")) { + if (/^report\s+(bugs?|issues?)\b/i.test(issue.title)) { // Tracking issue for a third-party dns/deploy/notify api: // no upgrade boilerplate; assign it to the opener and label it. await github.rest.issues.addAssignees({ From cf3eab95ee85fb38fa8e62e1f4c9b35eee14c789 Mon Sep 17 00:00:00 2001 From: lwohn-creo Date: Fri, 10 Jul 2026 04:07:12 +0200 Subject: [PATCH 139/224] Add creoline API as DNS provider (#7100) * New Banner Updated README to include responsive images for dark and light modes. * acme-sh-creoline-as-dns-provider * acme-sh-creoline-as-dns-provider - Review changes implemented according code review * acme-sh-creoline-as-dns-provider - Review changes implemented according second code review, minding --cron * acme-sh-creoline-as-dns-provider - Remove debug code * acme-sh-creoline-as-dns-provider - shfmt formatting according Code of conduct --------- Co-authored-by: neil Co-authored-by: ZeroSSL-Andreas Co-authored-by: Steven Kauschke --- dnsapi/dns_creoline.sh | 181 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 dnsapi/dns_creoline.sh diff --git a/dnsapi/dns_creoline.sh b/dnsapi/dns_creoline.sh new file mode 100644 index 00000000..9d04af4f --- /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\"[[:space:]]*:[[:space:]]*[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\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") + _debug _sub_domain "$_sub_domain" + + _domain=$(echo "$response" | _egrep_o "\"domain\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | 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\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \") + _err "Error: $message" + _err "URI:$uri" + return 1 + fi + + return 0 +} From 1324dcd4720274ac328165a54a06f0055b4e93e4 Mon Sep 17 00:00:00 2001 From: invario <67800603+invario@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:10:44 -0700 Subject: [PATCH 140/224] Docker: update crontab used (#7111) Signed-off-by: invario <67800603+invario@users.noreply.github.com> --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 55a9cc67..229e4830 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,8 +81,8 @@ if [ \"\$1\" = \"daemon\" ]; then \n \ echo \"\$LE_CONFIG_HOME/crontab not found, generating one\" \n \ time=\$(date -u \"+%s\") \n \ random_minute=\$((\$time % 60)) \n \ - random_hour=\$((\$time / 60 % 24)) \n \ - echo \"\$random_minute \$random_hour * * * \\\"\$LE_WORKING_DIR\\\"/acme.sh --cron --home \\\"\$LE_WORKING_DIR\\\" --config-home \\\"\$LE_CONFIG_HOME\\\"\" > \"\$LE_CONFIG_HOME\"/crontab \n \ + random_hour=\$((\$time / 60 % 6)) \n \ + echo \"\$random_minute \$random_hour,\$((\$random_hour + 6)),\$((\$random_hour + 12)),\$((\$random_hour + 18)) * * * \\\"\$LE_WORKING_DIR\\\"/acme.sh --cron --home \\\"\$LE_WORKING_DIR\\\" --config-home \\\"\$LE_CONFIG_HOME\\\"\" > \"\$LE_CONFIG_HOME\"/crontab \n \ fi \n \ echo \"Running Supercronic using crontab at \$LE_CONFIG_HOME/crontab\" \n \ exec -- /usr/bin/supercronic \"\$LE_CONFIG_HOME/crontab\" \n \ From bed15ba8447e93c3052442900955894d79d8daf7 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 10 Jul 2026 10:23:45 +0800 Subject: [PATCH 141/224] dns_freedns.sh: use grep -E, BRE \| alternation is a GNU extension OpenBSD grep treats \| in a BRE as a literal | character, so _freedns_domain_id never matched any row and every domain lookup failed with "Domain not found". Switch to ERE with -E, keeping the parens escaped so the (.*) suffix branch still requires literal parentheses and does not widen the match (e.g. searching example.com must not match example.company). Reported-by: @katiekloss @boretom Ref: https://github.com/acmesh-official/acme.sh/issues/2305 --- dnsapi/dns_freedns.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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... From 83b52e0cd77e3fd189e1508dde9da08b9f50b94f Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 10 Jul 2026 10:38:52 +0800 Subject: [PATCH 142/224] notify/smtp.sh: add --crlf so curl sends CRLF line endings Postfix with smtpd_forbid_bare_newline (default hardening since 3.9, after SMTP smuggling) rejects the message with "521 5.5.2 Error: bare received". RFC 5321 requires CRLF. The python sender is unaffected (smtplib already emits CRLF). fix https://github.com/acmesh-official/acme.sh/issues/7104 --- notify/smtp.sh | 1 + 1 file changed, 1 insertion(+) 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" From 45c0ad4112557599d5dd1725c7c6048efa563fd1 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 10 Jul 2026 11:05:40 +0800 Subject: [PATCH 143/224] Add _cleardeployconf to clear deploy hook keys from domain conf Mirrors _clearaccountconf_mutable: clears the SAVED_ prefixed key and the legacy unprefixed key. Replaces the local copy in synology_dsm.sh and the direct _cleardomainconf call in multideploy.sh. Closes #4722. Thanks to @sg1888. --- acme.sh | 7 +++++++ deploy/multideploy.sh | 2 +- deploy/synology_dsm.sh | 5 ----- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/acme.sh b/acme.sh index 4f1c0336..b072c6cf 100755 --- a/acme.sh +++ b/acme.sh @@ -2594,6 +2594,13 @@ _savedeployconf() { _cleardomainconf "$1" } +#key +_cleardeployconf() { + _cleardomainconf "SAVED_$1" + #remove later + _cleardomainconf "$1" +} + #key _getdeployconf() { _rac_key="$1" diff --git a/deploy/multideploy.sh b/deploy/multideploy.sh index ef920f64..375668ec 100644 --- a/deploy/multideploy.sh +++ b/deploy/multideploy.sh @@ -210,7 +210,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/synology_dsm.sh b/deploy/synology_dsm.sh index 502bc59b..202e8188 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -424,11 +424,6 @@ _temp_admin_cleanup() { fi } -#_cleardeployconf key -_cleardeployconf() { - _cleardomainconf "SAVED_$1" -} - # key _check2cleardeployconfexp() { _key="$1" From 534a1714dcbb79699407a4f9e0f2137ca92b1eb7 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 10 Jul 2026 11:20:08 +0800 Subject: [PATCH 144/224] dns_me.sh: use LC_ALL=C so the request date header is always English LC_ALL in the environment overrides both LC_TIME and LANG, so LANG=C alone still produced localized day/month names on non-English systems and DNS Made Easy rejected the request date header. An LC_ALL=C command prefix beats every locale variable (same pattern as dns_oci.sh). Fixes #4272. Closes #4271. Thanks to @Nickinthebox. --- dnsapi/dns_me.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 90b4795bb1a75712258b523d7bcec9fdd3e2c635 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 10 Jul 2026 11:22:20 +0800 Subject: [PATCH 145/224] issue: strip the trailing dot of a fully-qualified alias domain A trailing dot in --domain-alias/--challenge-alias was passed through to the dnsapi hook verbatim. Providers with exact-match record-name lookups (e.g. Cloudflare's name= filter) then never find the record, so rm never deletes it and relic TXT records accumulate on every issue. Stripping in issue() also fixes certs with a dotted alias already saved in domain.conf. fix https://github.com/acmesh-official/acme.sh/issues/4636 --- acme.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/acme.sh b/acme.sh index b072c6cf..84b38068 100755 --- a/acme.sh +++ b/acme.sh @@ -5350,6 +5350,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 From fa763db1051e15978aa11b5e8721e7c82ae3314e Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 10 Jul 2026 12:05:28 +0800 Subject: [PATCH 146/224] dns_pleskxml.sh: use grep -F when matching interpolated values fulldomain/txtvalue/root_domain_name were interpolated into grep regex patterns; match them as fixed strings instead. from https://github.com/acmesh-official/acme.sh/pull/7031 --- dnsapi/dns_pleskxml.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 2af543a358a1ac1cdb316d8d551db6bd41a99550 Mon Sep 17 00:00:00 2001 From: Jan Pieper Date: Fri, 10 Jul 2026 08:42:57 +0200 Subject: [PATCH 147/224] Fix typo (#6924) --- deploy/synology_dsm.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index 202e8188..e19024c7 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -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 From 660a5e322c24c4975bb416a52de288474ae4aabf Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 10 Jul 2026 18:37:13 +0800 Subject: [PATCH 148/224] deploy/synology_dsm.sh: use grep -Eo '[0-9]+' when extracting error codes grep -o '[0-9]*' can match the empty string; GNU grep skips empty matches but BSD greps handle them differently, breaking the 2FA login flow on OpenBSD. Force a non-empty match at all three sites. from https://github.com/acmesh-official/acme.sh/pull/6725 --- deploy/synology_dsm.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index e19024c7..75497671 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -234,7 +234,7 @@ 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 @@ -269,7 +269,7 @@ 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 @@ -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 From 2058a77d83c0211e5a5b09cc2924eae6a944b3c3 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 10 Jul 2026 19:37:36 +0800 Subject: [PATCH 149/224] acme.sh: fix variable name typo _excapedAlgnames -> _escapedAltnames from https://github.com/acmesh-official/acme.sh/pull/6547 --- acme.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/acme.sh b/acme.sh index 84b38068..6495a90e 100755 --- a/acme.sh +++ b/acme.sh @@ -1443,13 +1443,13 @@ _readSubjectAltNamesFromCSR() { _debug _dnsAltnames "$_dnsAltnames" # escape the wildcard '*' so it is not taken as a regex operator by grep/sed below - _excapedAlgnames="$(echo "$_dnsAltnames" | tr '*' '#')" - _debug _excapedAlgnames "$_excapedAlgnames" + _escapedAltnames="$(echo "$_dnsAltnames" | tr '*' '#')" + _debug _escapedAltnames "$_escapedAltnames" _escapedSubject="$(echo "$_csrsubj" | tr '*' '#')" _debug _escapedSubject "$_escapedSubject" - if _contains "$_excapedAlgnames," "DNS:$_escapedSubject,"; then + if _contains "$_escapedAltnames," "DNS:$_escapedSubject,"; then _debug "AltNames contains subject" - _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" From 98c30912fb5a58f3a50b6263ec0e30574c5ef8c1 Mon Sep 17 00:00:00 2001 From: "Andrew V." Date: Fri, 10 Jul 2026 15:36:42 +0300 Subject: [PATCH 150/224] 2024-12-24 - Ensure that $PDNS_Url has no trailing slash ('/'). (#6171) --- dnsapi/dns_pdns.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dnsapi/dns_pdns.sh b/dnsapi/dns_pdns.sh index ec19ad25..847a1af1 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" From 58cd667d6573262eb1061b51a18b21f2721e4fb0 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 10 Jul 2026 20:47:20 +0800 Subject: [PATCH 151/224] dns_njalla.sh: accept string record ids when removing records The Njalla API returns record ids as JSON strings now; the numeric-only pattern matched nothing, so the removal never found the record id. Match both quoted and bare ids. from https://github.com/acmesh-official/acme.sh/pull/5121 --- dnsapi/dns_njalla.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From ebde8345ae154e621087341eecd691b26bbc555b Mon Sep 17 00:00:00 2001 From: CV Date: Fri, 10 Jul 2026 14:55:23 +0200 Subject: [PATCH 152/224] dns_ispconfig.sh client_id not numeric at ispconfig v 3.2.7p1 (#4999) Getting client_id failed due to incorrect extraction! At least in version 3.2.7p1 and probably later the plugin is not working any more properly. The result of ```curResult="$(_post "${curData}" "${ISPC_Api}?client_get_id")"``` is something like this ```Result of _ISPC_ClientGetID: '[Tue Jan 23 11:44:57 CET 2024] Retrying post {"code":"ok","message":"","response":3}[Tue Jan 23 11:44:57 CET 2024] _hcode 0'```. The parsing code does not work properly and leaves a non numeric value such as ```Client ID: '3[Tue Jan 23 11'```. --- dnsapi/dns_ispconfig.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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]*) From 50e5e771d56a5989e0bc24df3b422059df63669b Mon Sep 17 00:00:00 2001 From: Roman Lumetsberger Date: Fri, 10 Jul 2026 13:02:06 +0000 Subject: [PATCH 153/224] Feature: Support other shells then sh (#4877) * Add support for DEPLOY_SSH_REMOTE_SHELL * allow to configure quoting of remote cmd string * shell check and shellfmt fixes --- deploy/ssh.sh | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/deploy/ssh.sh b/deploy/ssh.sh index 82b0382c..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 @@ -434,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 From fef90e15e131b8fa2f47dad471aa37dcdba4f772 Mon Sep 17 00:00:00 2001 From: Mike Lei Date: Fri, 10 Jul 2026 21:56:54 +0800 Subject: [PATCH 154/224] Fix name.com DNS API for processing IDNs (#4381) --- dnsapi/dns_namecom.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_namecom.sh b/dnsapi/dns_namecom.sh index 1ba6a6e5..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)}" From 2e4acba1055ea7b82e79f4f0ac2c7264796f7b00 Mon Sep 17 00:00:00 2001 From: Sasha Reid Date: Sat, 11 Jul 2026 04:04:49 +0200 Subject: [PATCH 155/224] Microwavenby dns hostinger (#6843) * [Microwavenby--dns_hostinger] Adding initial dns support for Hostinger.com * [Microwavenby--dns_hostinger] Creating a commit now that workflows are enabled * [Microwavenby--dns_hostinger] Correcting shellcheck. Why is this not automatic? * [Microwavenby-dns-hostinger] Responding to comments from Neil * [dns-hostinger] SHfmt and Shellcheck * [dns-hostinger] Writing non-greedy-ish regexes. correcting copypasta --- dnsapi/dns_hostinger.sh | 196 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100755 dnsapi/dns_hostinger.sh diff --git a/dnsapi/dns_hostinger.sh b/dnsapi/dns_hostinger.sh new file mode 100755 index 00000000..665c65da --- /dev/null +++ b/dnsapi/dns_hostinger.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_hostinger_info='Hostinger +Site: Hostinger.com +Domains: hostinger.nl +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_hostinger +Options: + HOSTINGER_Token API Key +Issues: https://github.com/acmesh-official/acme.sh/issues/6831 +Author: Sasha Reid +' + +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 +} From 9366c2e065d1ef006d18fe97965eb85b3a8f3a59 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 11 Jul 2026 11:42:32 +0800 Subject: [PATCH 156/224] dns_cpanel: resolve the most specific zone in _get_root With both domain.tld and sub.domain.tld zones on the account, the first endswith hit could pick the parent zone while cPanel stores the record in the most specific one, so the cleanup never found the record and left an orphaned _acme-challenge TXT entry. Pick the longest matching zone with an exact literal suffix match (_endswith treats the needle as a regex, letting xdomain.tld wrongly match zone domain.tld). https://github.com/acmesh-official/acme.sh/issues/6807 --- dnsapi/dns_cpanel.sh | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/dnsapi/dns_cpanel.sh b/dnsapi/dns_cpanel.sh index a6991403..3868c679 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 } From 2215f1b988dd544f186b9ae05d0a7061bf92b80f Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 11 Jul 2026 11:56:58 +0800 Subject: [PATCH 157/224] notify: clear inherited _H1.._H5 before running each notify hook The dns/deploy hooks export _H1.._H5 in the main process, and the notify hooks run in a subshell that inherits them. A hook that does not overwrite every slot (ntfy without NTFY_TOKEN, slack, telegram, etc.) sent the stale headers with its request, leaking another service's Authorization credentials to the notify endpoint. https://github.com/acmesh-official/acme.sh/issues/6801 --- acme.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/acme.sh b/acme.sh index 6495a90e..9ec92585 100755 --- a/acme.sh +++ b/acme.sh @@ -7765,6 +7765,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 From 04e0422526fc5723c3ce4fe88c4e39e9af8c7e2d Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 09:30:46 +0800 Subject: [PATCH 158/224] precheck: log the socat recommendation with _info instead of _err Missing socat only matters for standalone mode; the text even says the warning can be ignored. Printing it to stderr made every --upgrade in a cron noisy for DNS-only users who redirect stdout. https://github.com/acmesh-official/acme.sh/issues/6525 --- acme.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index 9ec92585..04002193 100755 --- a/acme.sh +++ b/acme.sh @@ -7420,9 +7420,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 From 22a5ae3cebe762017baeb3e14f4b3c71d3db6a13 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 09:34:59 +0800 Subject: [PATCH 159/224] dns_cloudns: include the server response in the login error https://github.com/acmesh-official/acme.sh/issues/6520 --- dnsapi/dns_cloudns.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_cloudns.sh b/dnsapi/dns_cloudns.sh index 23a219da..2c543271 100755 --- a/dnsapi/dns_cloudns.sh +++ b/dnsapi/dns_cloudns.sh @@ -135,7 +135,7 @@ _dns_cloudns_init_check() { _dns_cloudns_http_api_call "dns/login.json" "" if ! _contains "$response" "\"status\":\"Success\""; then - _err "Invalid CLOUDNS_AUTH_ID or CLOUDNS_AUTH_PASSWORD. Please check your login credentials." + _err "Invalid CLOUDNS_AUTH_ID or CLOUDNS_AUTH_PASSWORD. Server response: $response" return 1 fi From 76811857a09fe7d63036c05555630ff9a1901f13 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 09:35:22 +0800 Subject: [PATCH 160/224] dns_cpanel: quote inner expansion in suffix strip (SC2295) --- dnsapi/dns_cpanel.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_cpanel.sh b/dnsapi/dns_cpanel.sh index 3868c679..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="${fulldomain%.$_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 From 7d0283ca2cced9d6eb9985e36de96d15e902b556 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 10:26:40 +0800 Subject: [PATCH 161/224] dns_zonomi: make the API endpoint configurable via ZM_Api RimuHosting (which owns zonomi) exposes the identical API at https://rimuhosting.com/dns/dyndns.jsp; an overridable endpoint serves both providers with one hook. The default stays zonomi.com and is not written to the account conf. https://github.com/acmesh-official/acme.sh/issues/6475 --- dnsapi/dns_zonomi.sh | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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" From a49f8c1992c26adb6c33a5e7425601297709472f Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 10:49:49 +0800 Subject: [PATCH 162/224] mydevil: replace BSD-only cut -w with tr + plain cut cut -w (split on whitespace) is a FreeBSD extension unknown to GNU coreutils; squeeze blanks into tabs with tr first so the field extraction is POSIX. https://github.com/acmesh-official/acme.sh/issues/6452 --- deploy/mydevil.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 } From e94631de44e08a826c569e149ed45f7edc70e720 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 11:20:53 +0800 Subject: [PATCH 163/224] dns_pdns: probe zones with the server-side name filter in _get_root The unfiltered GET /zones lists every zone on the server; with large installations (100k zones) root-zone detection took minutes per domain. Probe each walk-up candidate with ?zone= instead (exact match per the PowerDNS API docs); servers that ignore the parameter return the full list, which the existing check still handles. https://github.com/acmesh-official/acme.sh/issues/6382 --- dnsapi/dns_pdns.sh | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/dnsapi/dns_pdns.sh b/dnsapi/dns_pdns.sh index 847a1af1..72a58af0 100755 --- a/dnsapi/dns_pdns.sh +++ b/dnsapi/dns_pdns.sh @@ -189,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 From 44c045b0569b2ec3a7b3b65451a88eaba5f05394 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 11:36:45 +0800 Subject: [PATCH 164/224] notify: add the customscript hook The wiki has documented "21. Set notification for customscript" since 2022 but the implementation (#4193) was never merged, so following the wiki failed with "Cannot find the hook file". Same interface as documented: the script gets subject, content and status code as three arguments. Unlike #4193, the target script is invoked directly instead of through eval -- the subject/content contain domain names and CA messages, and eval would allow command injection through them. https://github.com/acmesh-official/acme.sh/issues/6377 --- notify/customscript.sh | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 notify/customscript.sh 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 +} From c0d62eb9342e592523b1ee98090f1150dd76cc32 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 11:43:07 +0800 Subject: [PATCH 165/224] wiki-guard: skip on forks (no .wiki repo to check out) --- .github/workflows/wiki-guard.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml index 709fb8ea..dbe3dd8d 100644 --- a/.github/workflows/wiki-guard.yml +++ b/.github/workflows/wiki-guard.yml @@ -37,6 +37,9 @@ concurrency: jobs: guard: + # Forks have no .wiki repository, so the checkout below would + # fail there -- run only in the upstream repository. + if: github.repository == 'acmesh-official/acme.sh' runs-on: ubuntu-latest steps: - name: Checkout wiki repository From a9590c5bd7d9cdd32b8dda68bb00002feb43ca2a Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 11:49:53 +0800 Subject: [PATCH 166/224] workflows: run issue/wiki automation only in the upstream repo --- .github/workflows/blacklist-command.yml | 3 ++- .github/workflows/revert-command.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/blacklist-command.yml b/.github/workflows/blacklist-command.yml index 4d0ff5f3..35e53677 100644 --- a/.github/workflows/blacklist-command.yml +++ b/.github/workflows/blacklist-command.yml @@ -21,7 +21,8 @@ concurrency: jobs: blacklist: - if: startsWith(github.event.issue.title, 'blacklist:') + # Upstream only: forks have no .wiki repository to push to. + if: github.repository == 'acmesh-official/acme.sh' && startsWith(github.event.issue.title, 'blacklist:') runs-on: ubuntu-latest steps: - name: Check authorization diff --git a/.github/workflows/revert-command.yml b/.github/workflows/revert-command.yml index 7a792bf4..03161bbb 100644 --- a/.github/workflows/revert-command.yml +++ b/.github/workflows/revert-command.yml @@ -21,7 +21,8 @@ concurrency: jobs: revert: - if: startsWith(github.event.issue.title, 'revert:') + # Upstream only: forks have no .wiki repository to push to. + if: github.repository == 'acmesh-official/acme.sh' && startsWith(github.event.issue.title, 'revert:') runs-on: ubuntu-latest steps: - name: Check authorization From 897e2197433090c24bf55266d35a48987f1a60b0 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 12:12:18 +0800 Subject: [PATCH 167/224] issue: never schedule the default renewal past the cert expiry The default schedule was a fixed CertCreateTime + RenewalDays - 1 day, which passes notAfter entirely for short-lived certs (internal CAs today, the CA/B SC-081 47-day maximum later) and leaves an expired cert in place for weeks. Extract the arithmetic into _calc_next_renew_time and cap it at one day before expiry (one hour for lifetimes of 24h or less, mirroring --valid-to scheduling). CAs with ARI are unaffected -- the ARI window still overrides afterwards. https://github.com/acmesh-official/acme.sh/issues/6305 --- acme.sh | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index 04002193..00ec7ef4 100755 --- a/acme.sh +++ b/acme.sh @@ -1968,6 +1968,33 @@ _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" +} + _mktemp() { if _exists mktemp; then if mktemp 2>/dev/null; then @@ -5946,8 +5973,12 @@ $_authorizations_map" 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 From 099d88e6a99714f0c8cd87d5eeaaed9e2c871d9f Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 12:22:28 +0800 Subject: [PATCH 168/224] dns_knot: KNOT_KEY takes the TSIG key data, not a file path The knsupdate "key" statement wants "[alg:]name secret"; the info block wrongly described the option as a file path. https://github.com/acmesh-official/acme.sh/issues/6293 --- dnsapi/dns_knot.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_knot.sh b/dnsapi/dns_knot.sh index 5636804a..b5ba32f0 100644 --- a/dnsapi/dns_knot.sh +++ b/dnsapi/dns_knot.sh @@ -5,7 +5,7 @@ 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=" ' # See also dns_nsupdate.sh From eacf0d6a873798753dbdfee48347b7b917f373ba Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 12:32:54 +0800 Subject: [PATCH 169/224] issue bot: tell reporters to redact secrets before posting logs Debug logs occasionally contain private keys or tokens (issue 6267); the code-side leak in the haproxy hook was fixed by #6268, this adds the missing warning to the auto-comment that asks for logs. --- .github/workflows/issue.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index f72a1f06..00e9ddc5 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -82,5 +82,5 @@ jobs: issue_number: issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: "Please upgrade to the latest code and try again first. Maybe it's already fixed. ```acme.sh --upgrade``` If it's still not working, please provide the log with `--debug 2`, otherwise, nobody can help you." + body: "Please upgrade to the latest code and try again first. Maybe it's already fixed. ```acme.sh --upgrade``` If it's still not working, please provide the log with `--debug 2`, otherwise, nobody can help you. Before posting the log, review it and REDACT any secrets: private keys (`-----BEGIN ... PRIVATE KEY-----` blocks), API tokens and passwords." }) \ No newline at end of file From 2c51ac1c277b691658f57605d83664731fc77170 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 12:45:10 +0800 Subject: [PATCH 170/224] dns_dynu: include the server response in the authentication error Same as the dns_cloudns change: a bare "Authentication failed" hides the actual API error and makes reports undiagnosable. --- dnsapi/dns_dynu.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_dynu.sh b/dnsapi/dns_dynu.sh index 1d1fc311..612756c5 100644 --- a/dnsapi/dns_dynu.sh +++ b/dnsapi/dns_dynu.sh @@ -214,11 +214,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 From 3989eef5e26d9eb84c9abd0a35d7d3ad955f4e0d Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 13:00:12 +0800 Subject: [PATCH 171/224] cpanel_uapi: don't spill a redirection error when the key file is absent With --signcsr the private key never exists in the cert home, so every renewal printed ".../domain.key: No such file or directory" from the shell redirection. Skip the key read in that case; the install_ssl call already ran with an empty key there and cPanel keeps the installed one. https://github.com/acmesh-official/acme.sh/issues/6228 --- deploy/cpanel_uapi.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/deploy/cpanel_uapi.sh b/deploy/cpanel_uapi.sh index 16b622bb..156044eb 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" From ebb5cc4981ac38994b124441ef38b961ef565f27 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 16:04:44 +0800 Subject: [PATCH 172/224] deploy/synology_dsm.sh: remove bashisms, keep the hook POSIX sh The hook is sourced by acme.sh, so the bash shebang never takes effect: under dash, `[ x == y ]` fails with "unexpected operator", the 403 branch never triggers and 2FA-OTP login is skipped. Replace `==` with `=` and use the standard sh shebang. --- deploy/synology_dsm.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index 75497671..d05e503a 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 @@ -238,7 +238,7 @@ synology_dsm_deploy() { _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 @@ -274,22 +274,22 @@ synology_dsm_deploy() { 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 be expired or invalid), please try again in a new terminal window." - elif [ "$error_code" == "404" ]; then + 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." From 237f2d9c3b3a54bd1cf7e5ce118de7ec4f9fc8ae Mon Sep 17 00:00:00 2001 From: Achmad Alif Nasrulloh Date: Fri, 10 Jul 2026 18:06:13 +0700 Subject: [PATCH 173/224] Add notify waha support --- notify/waha.sh | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100755 notify/waha.sh diff --git a/notify/waha.sh b/notify/waha.sh new file mode 100755 index 00000000..022d8916 --- /dev/null +++ b/notify/waha.sh @@ -0,0 +1,75 @@ +#!/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" + + export _H1="Content-Type: application/json" + if [ "$WAHA_API_KEY" ]; then + export _H2="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 +} From d45b6fe8e6b84a85e1a9bdb13ee6bbc6d647dc16 Mon Sep 17 00:00:00 2001 From: Achmad Alif Nasrulloh Date: Sat, 11 Jul 2026 18:42:53 +0700 Subject: [PATCH 174/224] fix(notify): waha: tighten response check --- notify/waha.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notify/waha.sh b/notify/waha.sh index 022d8916..989f57ac 100755 --- a/notify/waha.sh +++ b/notify/waha.sh @@ -65,7 +65,7 @@ waha_send() { _waha_url="${WAHA_URL}/api/sendText" response="$(_post "$_data" "$_waha_url" "" "POST" "application/json")" - if [ "$?" = "0" ] && _contains "$response" "id"; then + if [ "$?" = "0" ] && _contains "$response" "\"id\""; then _info "waha send success." return 0 fi From f5e7e6b2259e415b22e0098b5aec3a84ee681478 Mon Sep 17 00:00:00 2001 From: ACHMAD ALIF NASRULLOH <106044706+achmadalifn4@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:10:21 +0700 Subject: [PATCH 175/224] Merge pull request #7114 from achmadalifn4/master Add notify waha support --- notify/waha.sh | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100755 notify/waha.sh diff --git a/notify/waha.sh b/notify/waha.sh new file mode 100755 index 00000000..989f57ac --- /dev/null +++ b/notify/waha.sh @@ -0,0 +1,75 @@ +#!/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" + + export _H1="Content-Type: application/json" + if [ "$WAHA_API_KEY" ]; then + export _H2="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 +} From e828b285ad0c10fb811c7c77059ba233df372139 Mon Sep 17 00:00:00 2001 From: matthias-matze Date: Sun, 12 Jul 2026 10:13:42 +0200 Subject: [PATCH 176/224] Matthiasvpfr patch 1 (#7108) * Add files via upload * New Banner Updated README to include responsive images for dark and light modes. * Add files via upload Remove usage of jq and curl should be compliant with acme.sh api dev guide * Add files via upload * Add files via upload change CR LF to LF * Add files via upload missing CR * Add files via upload * Add files via upload * Add files via upload * Add files via upload * Add files via upload * Add files via upload correct auth * Add files via upload better manage group_id * Add files via upload manage wrong / missing domain * Add files via upload strip domain to manage subdomain requests * Add files via upload fix wrong id selection in get root * Add files via upload fix parsing of IDs * Add files via upload correct get_root to better handle unexisting domaines (acmetest) * Add files via upload correct token incorrect in auth * Add files via upload manage case web api reply is empty * Add files via upload try to resolve error when adding unexisting subdmain txt entry * Add files via upload correct domain parsing * Add files via upload revert changes when adding record (name) change rm to better handle complex urls * Add files via upload correct rm function to better manage records deletion * Add files via upload ensure auth variable arent lost during calls * Add files via upload try to keep autj variables accross executions * Add files via upload * Add files via upload * Add files via upload * Add files via upload * Add files via upload * Add files via upload fix stripping subdomains * Add files via upload * Add files via upload keep auth token instead of requesting it each time * Add files via upload debug * Add files via upload * Add files via upload * Add files via upload better manage record deletion to avoid orphans added some debug and checks * Add files via upload paginated api support for rm function * Add files via upload * Add files via upload delay to manage api 404 * Add files via upload enhance parsing of records in rm * Add files via upload fix incorrect record detection in rm * Add files via upload better manage filters on api to limit errors * Add files via upload try to handle 404 when requesting API too frequently * Add files via upload * Add files via upload sleep during auth * Add files via upload manage 404 errors in get_root * Add files via upload correct variable overide translate comments prefix all variables * Add files via upload correct variable * Add files via upload typo * Add files via upload * Add files via upload * Add files via upload * Add files via upload correct variable naming (_comlaude prefix missing) correct rm return code when non existing record typo * Add files via upload log an info instead of an error if no dns record found in RM function. --------- Co-authored-by: neil Co-authored-by: Matthiasvpfr Co-authored-by: ZeroSSL-Andreas --- dnsapi/dns_comlaude.sh | 248 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 dnsapi/dns_comlaude.sh diff --git a/dnsapi/dns_comlaude.sh b/dnsapi/dns_comlaude.sh new file mode 100644 index 00000000..2aa2dba9 --- /dev/null +++ b/dnsapi/dns_comlaude.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env sh + +# shellcheck disable=SC2034 +dns_comlaude_info='comlaude.com +Site: comlaude.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_comlaude +Options: + COMLAUDE_USERNAME User account + COMLAUDE_PASSWORD User password + COMLAUDE_API_KEY generated API key + COMLAUDE_GROUP_ID Group ID in comlaude user profile + Get it from the https://www.comlaude.com +Issues: github.com/acmesh-official/acme.sh/issues/7112 +' +# ===== CONFIG ===== +COMLAUDE_API="https://api.comlaude.com" + +########## AUTH ########## + +_comlaude_auth() { + _debug "Checking cached ComLaude token" + + # Try to get token from account.conf + if [ -z "$COMLAUDE_ACCESS_TOKEN" ]; then + COMLAUDE_ACCESS_TOKEN="$(_readaccountconf_mutable COMLAUDE_ACCESS_TOKEN)" + COMLAUDE_TOKEN_EXPIRY="$(_readaccountconf_mutable COMLAUDE_TOKEN_EXPIRY)" + fi + + _now=$(_time) + if [ -n "$COMLAUDE_ACCESS_TOKEN" ] && [ -n "$COMLAUDE_TOKEN_EXPIRY" ] && [ "$_now" -lt "$COMLAUDE_TOKEN_EXPIRY" ]; then + _debug "Using cached ComLaude token (valid ${COMLAUDE_TOKEN_EXPIRY} > ${_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 +} From cd486cfbb9d88a4ca4d173ba90f012d41e7a0564 Mon Sep 17 00:00:00 2001 From: Steven Qiu Date: Sun, 12 Jul 2026 16:15:59 +0800 Subject: [PATCH 177/224] Add Baidu Cloud CDN deploy hook (#6951) * add Baidu Cloud CDN deploy hook Code generated by GitHub Copilot with Claude Sonnet 4.6. Tested with local environment by human. * inline functions Code generated by OpenAI Codex with GPT-5.5 Sol. Tested with local environment by human. --- deploy/baidu_cdn.sh | 222 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 deploy/baidu_cdn.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 +} From dd6540ce46a52f94d4ec37fa4e46cf5e717e3ad2 Mon Sep 17 00:00:00 2001 From: amk1969 <32023825+amk1969@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:21:00 +0200 Subject: [PATCH 178/224] dns_ovh specific permission for record removal (#6386) Co-authored-by: amk --- dnsapi/dns_ovh.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 5a8c685fd3e39b958cad84004ba495467b4f53ca Mon Sep 17 00:00:00 2001 From: Kat Crichton-Seager Date: Sun, 12 Jul 2026 09:37:21 +0100 Subject: [PATCH 179/224] fix: dnsexit api rejects a TTL of zero, changed to 1 (minute) (#7107) --- dnsapi/dns_dnsexit.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_dnsexit.sh b/dnsapi/dns_dnsexit.sh index 6b10891c..b92482cf 100644 --- a/dnsapi/dns_dnsexit.sh +++ b/dnsapi/dns_dnsexit.sh @@ -25,7 +25,7 @@ dns_dnsexit_add() { return 1 fi - _dnsexit_zone_op add ',"ttl":0,"overwrite":false' + _dnsexit_zone_op add ',"ttl":1,"overwrite":false' } #Usage: fulldomain txtvalue From 447dc3c7e7e8fa45337980e2ad13e0185c1be4ff Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 17:21:03 +0800 Subject: [PATCH 180/224] dns_ali: convert IDN domains to punycode The Aliyun API only accepts punycode domain names, and a raw UTF-8 domain also breaks the request signature. Same _idn pattern as dns_namecom. https://github.com/acmesh-official/acme.sh/issues/4733 --- dnsapi/dns_ali.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_ali.sh b/dnsapi/dns_ali.sh index 62e54e0c..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)}" From 15a1067f1b0bc36c803c11e80ca965cf0e3e15d0 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 17:34:37 +0800 Subject: [PATCH 181/224] update-account: persist the new email into the CA conf "--update-account -m new@example.com" updated the contact on the CA but never saved it locally, so CA_EMAIL kept showing the old address on every later run. Save it in the success path like _regAccount does. https://github.com/acmesh-official/acme.sh/issues/4673 --- acme.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/acme.sh b/acme.sh index 00ec7ef4..85dccda6 100755 --- a/acme.sh +++ b/acme.sh @@ -4179,6 +4179,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" From b9ce911eb16de4648670ad7947a8f565e24479d8 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 19:21:40 +0800 Subject: [PATCH 182/224] installcronjob: never wipe existing cron jobs when crontab -l fails Piping a failed 'crontab -l' straight back into 'crontab -' replaced the whole crontab with just the acme.sh entry when the listing failed while jobs existed (seen on cPanel/CloudLinux jailshell). Capture the listing first and refuse to write unless the failure is the normal "no crontab for user" case. https://github.com/acmesh-official/acme.sh/issues/3079 --- acme.sh | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/acme.sh b/acme.sh index 85dccda6..0b0f3ce9 100755 --- a/acme.sh +++ b/acme.sh @@ -7003,15 +7003,35 @@ installcronjob() { return 1 fi _info "Installing cron job" - if ! $_CRONTAB -l 2>/dev/null | 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)" + if echo "$_cron_list_err" | grep -i "no crontab\|no fcrontab\|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 2>/dev/null | { - cat - echo "$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" + { + if [ "$_cron_entries" ]; then + echo "$_cron_entries" + fi + echo "$_cron_entry" } | $_CRONTAB_STDIN fi if [ "$?" != "0" ]; then From a6766d41868ce4aa991860a93f077eb8d460b9a9 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 19:21:48 +0800 Subject: [PATCH 183/224] dns_dnsimple: use mutable conf storage so a newly exported token wins The legacy plain _saveaccountconf copy in account.conf is sourced at startup and silently overrides a newly exported DNSimple_OAUTH_TOKEN, so rotated tokens never took effect. fixes https://github.com/acmesh-official/acme.sh/issues/3422 --- dnsapi/dns_dnsimple.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/dnsapi/dns_dnsimple.sh b/dnsapi/dns_dnsimple.sh index e262239a..257549b4 100644 --- a/dnsapi/dns_dnsimple.sh +++ b/dnsapi/dns_dnsimple.sh @@ -18,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." @@ -26,7 +27,7 @@ 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 retrieve account id" @@ -57,6 +58,12 @@ 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 retrieve account id" return 1 @@ -123,9 +130,9 @@ _get_root() { # returns _account_id _get_account_id() { - DNSimple_ACCOUNT_ID="${DNSimple_ACCOUNT_ID:-$(_readaccountconf DNSimple_ACCOUNT_ID)}" + DNSimple_ACCOUNT_ID="${DNSimple_ACCOUNT_ID:-$(_readaccountconf_mutable DNSimple_ACCOUNT_ID)}" if [ "$DNSimple_ACCOUNT_ID" ]; then - _saveaccountconf DNSimple_ACCOUNT_ID "$DNSimple_ACCOUNT_ID" + _saveaccountconf_mutable DNSimple_ACCOUNT_ID "$DNSimple_ACCOUNT_ID" _account_id="$DNSimple_ACCOUNT_ID" _debug _account_id "$_account_id" return 0 From 3fddea296207de9754727ebb5349ec50b0e8b176 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 19:21:56 +0800 Subject: [PATCH 184/224] dns_dynu: use mutable conf storage so newly exported credentials win Same stale-account.conf override as dns_dnsimple: the sourced plain copy shadowed newly exported Dynu_ClientId/Dynu_Secret. https://github.com/acmesh-official/acme.sh/issues/3216 --- dnsapi/dns_dynu.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/dnsapi/dns_dynu.sh b/dnsapi/dns_dynu.sh index 612756c5..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." From 5e33e9f5f170a49a269ddcff9c50ab226ae88d4f Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 19:22:03 +0800 Subject: [PATCH 185/224] dns_knot: add KNOT_ZONE for delegated subdomain zones The zone cannot be derived from the record name when the Knot server is only authoritative for a delegated subdomain; let the user name it explicitly, like NSUPDATE_ZONE. fixes https://github.com/acmesh-official/acme.sh/issues/2881 --- dnsapi/dns_knot.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dnsapi/dns_knot.sh b/dnsapi/dns_knot.sh index b5ba32f0..2b6d8ef4 100644 --- a/dnsapi/dns_knot.sh +++ b/dnsapi/dns_knot.sh @@ -6,6 +6,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_knot Options: KNOT_SERVER Server hostname. Default: "localhost". 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) From d621d6952aa1c815658bacd0aa2af44b4136d708 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 20:18:36 +0800 Subject: [PATCH 186/224] fix --- acme.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index 0b0f3ce9..ecea7bc2 100755 --- a/acme.sh +++ b/acme.sh @@ -7011,7 +7011,9 @@ installcronjob() { #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)" - if echo "$_cron_list_err" | grep -i "no crontab\|no fcrontab\|can't open" >/dev/null; then + #multiple -e instead of \| : BRE alternation is a GNU extension that + #BSD grep does not support + if echo "$_cron_list_err" | grep -i -e "no crontab" -e "no fcrontab" -e "can't open" >/dev/null; then _cron_entries="" else _err "Can not list the current cron jobs: $_cron_list_err" From 17964cfd6ef177988e07bd34a9f0c0388cd597db Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 22:28:56 +0800 Subject: [PATCH 187/224] installcronjob: Solaris grep takes only one -e, use separate greps The "no crontab" whitelist used multiple -e patterns, but Solaris /usr/bin/grep honors only a single -e, so a fresh install was refused there. Use one plain grep per message pattern, which every grep implementation supports (caught by le_test_installcronjob_no_wipe on the Solaris CI). --- acme.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index ecea7bc2..4615543b 100755 --- a/acme.sh +++ b/acme.sh @@ -7011,9 +7011,11 @@ installcronjob() { #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)" - #multiple -e instead of \| : BRE alternation is a GNU extension that - #BSD grep does not support - if echo "$_cron_list_err" | grep -i -e "no crontab" -e "no fcrontab" -e "can't open" >/dev/null; then + #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" From 020123d812e15fcce07d174e1ad74637c7c56c75 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 13 Jul 2026 08:30:43 +0800 Subject: [PATCH 188/224] dns_infomaniak: log zones response and fail early in _get_zone The sed in _get_zone passed the raw JSON through when the response contained no [{"fqdn":", so an API error turned the zone into "{" and the failure only surfaced later as POST /2/zones/{/records "method_not_found". Log the response at debug2, error out on non-success results, and parse fqdn position-independently. https://github.com/acmesh-official/acme.sh/issues/6851 --- dnsapi/dns_infomaniak.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/dnsapi/dns_infomaniak.sh b/dnsapi/dns_infomaniak.sh index 43ade8ce..6cc8a4a0 100755 --- a/dnsapi/dns_infomaniak.sh +++ b/dnsapi/dns_infomaniak.sh @@ -182,7 +182,11 @@ dns_infomaniak_rm() { _get_zone() { domain="$1" # Whatever the domain is, you can get the fqdn with the following. - # shellcheck disable=SC1004 - response=$(_get "${INFOMANIAK_API_URL}/2/domains/${domain}/zones" | sed 's/.*\[{"fqdn"\:"\(.*\)/\1/') - echo "${response%%\"*}" + response=$(_get "${INFOMANIAK_API_URL}/2/domains/${domain}/zones") + _debug2 "_get_zone response" "$response" + if ! _contains "$response" '"result":"success"'; then + _err "cannot get zones for ${domain}, response: ${response}" + return 1 + fi + echo "$response" | _egrep_o '"fqdn" *: *"[^"]*"' | _head_n 1 | cut -d '"' -f 4 } From 1a54307dbf3e8a1bebb3ddf3bf4efcf964b7557c Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 13 Jul 2026 10:43:08 +0800 Subject: [PATCH 189/224] account: keep restored ACCOUNT_URL in the EAB-already-bound path When re-registering an already-bound EAB account (HTTP 400 "not awaiting external account binding"), the else branch restored ACCOUNT_URL from ca.conf but the following unconditional `export ACCOUNT_URL="$_accUri"` clobbered it with an empty _accUri (never set on that path), so later signed requests failed with "A Key ID MUST be specified" / "account URL is empty". Assign the restored value to _accUri so the shared export uses it. https://github.com/acmesh-official/acme.sh/issues/3382 --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index 4615543b..b981b54e 100755 --- a/acme.sh +++ b/acme.sh @@ -4125,7 +4125,7 @@ _regAccount() { fi _savecaconf "ACCOUNT_URL" "$_accUri" else - ACCOUNT_URL="$(_readcaconf ACCOUNT_URL)" + _accUri="$(_readcaconf ACCOUNT_URL)" fi export ACCOUNT_URL="$_accUri" From 4558a8aa98d444db2dbbffdef38ab478b4229d09 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 13 Jul 2026 11:59:35 +0800 Subject: [PATCH 190/224] challenge: use portable [{] literal-brace in _egrep_o patterns (#968) _egrep_o falls back to a BRE sed expression on shells without egrep -o (Solaris, DD-WRT busybox). A bare "\{" there is a BRE interval operator and aborts ("sed: command garbled" / "Invalid content of \{\}"), so the challenge-status-invalid path extracted an empty error object and the CA's failure reason was lost. Replace the escaped braces with "[{]"/"[}]" bracket expressions, unambiguous literal braces in both BRE and ERE, at all four call sites (challenge type/error extraction and profiles). --- acme.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/acme.sh b/acme.sh index b981b54e..b53601ee 100755 --- a/acme.sh +++ b/acme.sh @@ -5304,7 +5304,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 @@ -5636,7 +5636,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" @@ -6581,7 +6581,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." @@ -7264,7 +7264,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" From 1a746d98b8d282435450446aae424df53616c39a Mon Sep 17 00:00:00 2001 From: NotAnotherHelloWorld Date: Tue, 14 Jul 2026 03:02:34 +0200 Subject: [PATCH 191/224] Deploy certificate to FortiGate firewall using API (#6236) * Deploy certificate to FortiGate firewall using API * Refactor FortiGate deployment functions Prefix private functions and working variables and use a timestamped certificate name. * Replace grep -o for POSIX compatibility --- deploy/fortigate.sh | 175 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 deploy/fortigate.sh 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 < Date: Tue, 14 Jul 2026 22:58:47 +1000 Subject: [PATCH 192/224] fix(dns_oci): read ~/.oci/config before cached account.conf values (#7124) The OCI DNS plugin cached the tenancy, user, region and signing key into acme.sh's account.conf at issuance and then, on subsequent runs, read those cached values before consulting ~/.oci/config. A value cached at issuance therefore permanently shadowed the config file, so editing ~/.oci/config afterwards (most visibly rotating the API signing key) had no effect and renewals kept using stale credentials, failing authentication. Resolve each field in the order: explicit environment variable, then ~/.oci/config when it exists, then the cached account.conf value as a fallback for env-only installs that have no config file. The signing key likewise prefers the key_file resolved from the environment or ~/.oci/config over any cached key. Signed-off-by: Avi Miller --- dnsapi/dns_oci.sh | 53 +++++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 22 deletions(-) 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 From 1dbabf0da95c8803012b3d006ed7094962ac9761 Mon Sep 17 00:00:00 2001 From: neil Date: Tue, 14 Jul 2026 21:12:43 +0800 Subject: [PATCH 193/224] valid-to: allow a negative --days to set the renewal margin A negative --days is anchored to the expiry, so it composes with a relative --valid-to: "--valid-to +30d --days -7" renews 7 days before the expiry instead of the hardcoded 1 day. A positive --days and any --days with a fixed-date --valid-to are still rejected. https://github.com/acmesh-official/acme.sh/issues/6570 --- acme.sh | 55 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/acme.sh b/acme.sh index b53601ee..3a73cd77 100755 --- a/acme.sh +++ b/acme.sh @@ -1995,6 +1995,26 @@ _calc_next_renew_time() { 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 @@ -5950,19 +5970,8 @@ $_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") @@ -8050,6 +8059,7 @@ Parameters: 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. @@ -9044,13 +9054,20 @@ _process() { _debug2 LE_WORKING_DIR "$LE_WORKING_DIR" - # --days and --valid-to are mutually exclusive by design: --valid-to pins - # the cert lifetime and the renewal time follows the expiry, so a - # creation-based --days schedule can not apply. + # --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 - _err "--days can not be used together with --valid-to." - _err "With --valid-to, the renewal time is derived from the expiry time automatically." - return 1 + 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 From 3faf65c46d7c35fde3f0a35f64092f1d9b0f6967 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 15 Jul 2026 20:41:35 +0800 Subject: [PATCH 194/224] fix https://github.com/acmesh-official/acme.sh/issues/1940#issuecomment-4971257867 --- acme.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index 3a73cd77..a5f53469 100755 --- a/acme.sh +++ b/acme.sh @@ -5838,7 +5838,7 @@ $_authorizations_map" return 1 fi - echo "$response" >"$CERT_PATH" + 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) @@ -5865,7 +5865,7 @@ $_authorizations_map" _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")" @@ -6072,6 +6072,15 @@ $_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() { + sed '/^[[:space:]]*$/d' +} + _split_cert_chain() { _certf="$1" _fullchainf="$2" From a82cf763cfe8a69f8329f4f0d2cfc4b5a8cb6240 Mon Sep 17 00:00:00 2001 From: Achmad Alif Nasrulloh Date: Thu, 16 Jul 2026 11:19:07 +0700 Subject: [PATCH 195/224] fix(notify): remove duplicate Content-Type header in waha hook --- notify/waha.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/notify/waha.sh b/notify/waha.sh index 989f57ac..573295e9 100755 --- a/notify/waha.sh +++ b/notify/waha.sh @@ -57,9 +57,8 @@ waha_send() { _debug "_data" "$_data" - export _H1="Content-Type: application/json" if [ "$WAHA_API_KEY" ]; then - export _H2="X-Api-Key: $WAHA_API_KEY" + export _H1="X-Api-Key: $WAHA_API_KEY" fi _waha_url="${WAHA_URL}/api/sendText" From 6d559ae69f6b18bd1b4e0087dafba695acbbd933 Mon Sep 17 00:00:00 2001 From: ACHMAD ALIF NASRULLOH <106044706+achmadalifn4@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:29:39 +0700 Subject: [PATCH 196/224] Add newline at end of waha.sh Fix missing newline at end of file. --- notify/waha.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notify/waha.sh b/notify/waha.sh index bbb1dc44..573295e9 100755 --- a/notify/waha.sh +++ b/notify/waha.sh @@ -71,4 +71,4 @@ waha_send() { _err "waha send error." _err "$response" return 1 -} \ No newline at end of file +} From 745a42f1936cff1d9853ae7ee9d6e12c1f98d344 Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 16 Jul 2026 20:09:10 +0800 Subject: [PATCH 197/224] minor --- .github/workflows/issue.yml | 45 ++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index 00e9ddc5..a25cd4ef 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -2,6 +2,8 @@ name: "Update issues" on: issues: types: [opened] + issue_comment: + types: [created] pull_request_target: types: [opened] @@ -32,6 +34,32 @@ jobs: } catch (e) { core.warning(`Failed to fetch the blacklist: ${e}`); } + // A comment on a closed tracking issue reopens it (the standard + // closing note promises this). Bots, blacklisted users and the + // maintainer's own comments don't reopen. + if (context.eventName === "issue_comment") { + const issue = context.payload.issue; + const commenter = context.payload.comment.user; + if (issue.pull_request || issue.state !== "closed") { + return; + } + if (!/^report\s+(bugs?|issues?)\b/i.test(issue.title)) { + return; + } + if (commenter.type === "Bot" || + commenter.login.toLowerCase() === "neilpang" || + blacklist.includes(commenter.login.toLowerCase())) { + return; + } + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: "open" + }); + return; + } + if (blacklist.includes(item.user.login.toLowerCase())) { if (context.payload.pull_request) { await github.rest.pulls.update({ @@ -63,7 +91,9 @@ jobs: } if (/^report\s+(bugs?|issues?)\b/i.test(issue.title)) { // Tracking issue for a third-party dns/deploy/notify api: - // no upgrade boilerplate; assign it to the opener and label it. + // no upgrade boilerplate; assign it to the opener, label it, + // then close it right away to keep the issue list clean. Any + // later comment reopens it (see the issue_comment handler). await github.rest.issues.addAssignees({ owner: context.repo.owner, repo: context.repo.repo, @@ -76,6 +106,19 @@ jobs: issue_number: issue.number, labels: ["3rd party api"] }); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: "Closing this tracking issue for now to keep the issue list clean. It remains the place to report problems with this provider -- if you hit a bug, comment here and the issue will be reopened." + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: "closed", + state_reason: "completed" + }); return; } await github.rest.issues.createComment({ From a836e747d1aea2d64e393bb5046471325e6dfa3a Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 17 Jul 2026 10:10:07 +0800 Subject: [PATCH 198/224] start 3.1.5 --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index a5f53469..5fa6882a 100755 --- a/acme.sh +++ b/acme.sh @@ -1,6 +1,6 @@ #!/usr/bin/env sh -VER=3.1.4 +VER=3.1.5 PROJECT_NAME="acme.sh" From 59a97d7f8b765a9ff0a27bc408b939903b46e551 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 17 Jul 2026 12:38:14 +0800 Subject: [PATCH 199/224] fix bug for solaris. dnsapi/deploy: remove POSIX character classes from sed/grep patterns Solaris /usr/bin/sed and /usr/bin/grep parse [[:space:]] etc. as a literal bracket set and silently mis-match. Replace with [ ]* for JSON matching, a printf-tab bracket for user-input trimming, and [0-9] for digits; also drop GNU-only sed -r/-E in rage4, selfhost and selectel, and reuse _strip_blank_lines in byteplus_alb. --- .github/workflows/vtag.yml | 32 ++++++++++++++++++++++++++++++++ acme.sh | 5 ++++- deploy/byteplus_alb.sh | 4 ++-- dnsapi/dns_bhosted.sh | 6 +++--- dnsapi/dns_creoline.sh | 8 ++++---- dnsapi/dns_czechia.sh | 13 ++++++++----- dnsapi/dns_hostup.sh | 8 ++++---- dnsapi/dns_infoblox_uddi.sh | 4 ++-- dnsapi/dns_poweradmin.sh | 2 +- dnsapi/dns_rage4.sh | 2 +- dnsapi/dns_selectel.sh | 2 +- dnsapi/dns_selfhost.sh | 7 +++++-- dnsapi/dns_udr.sh | 4 ++-- dnsapi/dns_yandex360.sh | 4 ++-- 14 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/vtag.yml diff --git a/.github/workflows/vtag.yml b/.github/workflows/vtag.yml new file mode 100644 index 00000000..e9f7e8df --- /dev/null +++ b/.github/workflows/vtag.yml @@ -0,0 +1,32 @@ +name: Mirror version tag + +# Historical release tags are plain version numbers ("3.1.3") and cannot be +# renamed. When a plain version tag is pushed (including the tag created by +# publishing a GitHub release), mirror it as a "v"-prefixed tag ("v3.1.3") +# pointing to the same object, so both forms exist. +# No retrigger loop: the tag filter never matches a "v"-prefixed tag, and +# refs created with GITHUB_TOKEN do not fire workflows anyway. + +on: + push: + tags: + - '[0-9]*' + +permissions: + contents: write + +jobs: + vtag: + if: github.repository == 'acmesh-official/acme.sh' + runs-on: ubuntu-latest + steps: + - name: Create the v-prefixed tag + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh api "repos/${{ github.repository }}/git/ref/tags/v${{ github.ref_name }}" >/dev/null 2>&1; then + echo "Tag v${{ github.ref_name }} already exists, nothing to do." + exit 0 + fi + gh api "repos/${{ github.repository }}/git/refs" -f ref="refs/tags/v${{ github.ref_name }}" -f sha="${{ github.sha }}" + echo "Created tag v${{ github.ref_name }} -> ${{ github.sha }}" diff --git a/acme.sh b/acme.sh index 5fa6882a..753b1159 100755 --- a/acme.sh +++ b/acme.sh @@ -6078,7 +6078,10 @@ $_authorizations_map" #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() { - sed '/^[[:space:]]*$/d' + #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() { 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/dnsapi/dns_bhosted.sh b/dnsapi/dns_bhosted.sh index 1493c60a..46ddd5dd 100644 --- a/dnsapi/dns_bhosted.sh +++ b/dnsapi/dns_bhosted.sh @@ -323,21 +323,21 @@ _bhosted_extract_id() { fi # JSON: "id":12345 - _id="$(printf "%s" "$_resp" | _egrep_o '"id"[[:space:]]*:[[:space:]]*[0-9]+' | _head_n 1 | tr -cd '0-9')" + _id="$(printf "%s" "$_resp" | _egrep_o '"id"[ ]*:[ ]*[0-9]+' | _head_n 1 | tr -cd '0-9')" if [ -n "$_id" ]; then printf "%s" "$_id" return 0 fi # key=value: id=12345 - _id="$(printf "%s" "$_resp" | _egrep_o '(^|[[:space:][:punct:]])id[[:space:]]*=[[:space:]]*[0-9]+' | _head_n 1 | tr -cd '0-9')" + _id="$(printf "%s" "$_resp" | _egrep_o '(^|[^0-9a-zA-Z])id[ ]*=[ ]*[0-9]+' | _head_n 1 | tr -cd '0-9')" if [ -n "$_id" ]; then printf "%s" "$_id" return 0 fi # "record id 12345" / "recordid 12345" - _id="$(printf "%s" "$_resp" | _egrep_o '(record[[:space:]]*id|recordid)[^0-9]*[0-9]+' | _head_n 1 | tr -cd '0-9')" + _id="$(printf "%s" "$_resp" | _egrep_o '(record[ ]*id|recordid)[^0-9]*[0-9]+' | _head_n 1 | tr -cd '0-9')" if [ -n "$_id" ]; then printf "%s" "$_id" return 0 diff --git a/dnsapi/dns_creoline.sh b/dnsapi/dns_creoline.sh index 9d04af4f..f4d76f8e 100644 --- a/dnsapi/dns_creoline.sh +++ b/dnsapi/dns_creoline.sh @@ -75,7 +75,7 @@ dns_creoline_rm() { return 1 fi - record_id=$(echo "$response" | _egrep_o "\"id\"[[:space:]]*:[[:space:]]*[0-9]+" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") + 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 @@ -108,10 +108,10 @@ _get_root() { return 1 fi - _sub_domain=$(echo "$response" | _egrep_o "\"subDomain\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") + _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\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") + _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 @@ -171,7 +171,7 @@ _creoline_rest() { _err "URI:$uri" return 1 elif _contains "$response" "message"; then - message=$(echo "$response" | _egrep_o "\"message\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \") + message=$(echo "$response" | _egrep_o "\"message\"[ ]*:[ ]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \") _err "Error: $message" _err "URI:$uri" return 1 diff --git a/dnsapi/dns_czechia.sh b/dnsapi/dns_czechia.sh index e2ffcf50..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." @@ -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_hostup.sh b/dnsapi/dns_hostup.sh index 73189189..a3d9174a 100644 --- a/dnsapi/dns_hostup.sh +++ b/dnsapi/dns_hostup.sh @@ -441,18 +441,18 @@ _hostup_json_extract() { input="${2:-$line}" # First try to extract quoted values (strings) - quoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | _head_n 1)" + quoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[ ]*:[ ]*\"[^\"]*\"" | _head_n 1)" if [ -n "$quoted_match" ]; then printf "%s" "$quoted_match" | cut -d : -f2- | - sed 's/^[[:space:]]*"//' | - sed 's/"[[:space:]]*$//' | + sed 's/^[ ]*"//' | + sed 's/"[ ]*$//' | sed 's/\\"/"/g' return 0 fi # Fallback for unquoted values (e.g., numeric IDs) - unquoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[[:space:]]*:[[:space:]]*[^,}]*" | _head_n 1)" + unquoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[ ]*:[ ]*[^,}]*" | _head_n 1)" if [ -n "$unquoted_match" ]; then printf "%s" "$unquoted_match" | cut -d : -f2- | diff --git a/dnsapi/dns_infoblox_uddi.sh b/dnsapi/dns_infoblox_uddi.sh index 4b15088a..902cc700 100644 --- a/dnsapi/dns_infoblox_uddi.sh +++ b/dnsapi/dns_infoblox_uddi.sh @@ -117,7 +117,7 @@ dns_infoblox_uddi_rm() { return 0 fi - record_id=$(echo "$response" | _egrep_o '"id":[[:space:]]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + record_id=$(echo "$response" | _egrep_o '"id":[ ]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4) _debug "record_id" "$record_id" if [ -z "$record_id" ]; then @@ -178,7 +178,7 @@ _get_root() { # Check if response contains results (even if empty) if _contains "$response" '"results"'; then # Extract zone ID - must match the pattern dns/auth_zone/... - zone_id=$(echo "$response" | _egrep_o '"id":[[:space:]]*"dns/auth_zone/[^"]*"' | _head_n 1 | cut -d '"' -f 4) + zone_id=$(echo "$response" | _egrep_o '"id":[ ]*"dns/auth_zone/[^"]*"' | _head_n 1 | cut -d '"' -f 4) if [ -n "$zone_id" ]; then # Found the zone _domain="$h" diff --git a/dnsapi/dns_poweradmin.sh b/dnsapi/dns_poweradmin.sh index db31fa4f..a4c81835 100644 --- a/dnsapi/dns_poweradmin.sh +++ b/dnsapi/dns_poweradmin.sh @@ -227,7 +227,7 @@ _poweradmin_rest() { return 1 fi - if printf '%s' "$response" | grep -q '"success"[[:space:]]*:[[:space:]]*false'; then + if printf '%s' "$response" | grep -q '"success"[ ]*:[ ]*false'; then _err "API reported failure on $method $ep" _debug "Response: $response" return 1 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_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 782a5d5f..25130146 100644 --- a/dnsapi/dns_selfhost.sh +++ b/dnsapi/dns_selfhost.sh @@ -42,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" @@ -54,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) 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_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" From 7fa301821911ca93bbf82ad77edef577ac07785e Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 17 Jul 2026 22:03:00 +0800 Subject: [PATCH 200/224] feat: add ACME_PACKAGED for distro-packaged installs When ACME_PACKAGED is set (e.g. exported by a distro package wrapper): - --install does not copy the script or the hooks into LE_WORKING_DIR; the cron job and the shell alias point to the packaged script instead - --upgrade, --install-online and the cron AUTO_UPGRADE path refuse and point to the system package manager - --uninstall does not remove the packaged files https://github.com/acmesh-official/acme.sh/issues/7135 --- acme.sh | 101 ++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 68 insertions(+), 33 deletions(-) diff --git a/acme.sh b/acme.sh index 753b1159..274af3ac 100755 --- a/acme.sh +++ b/acme.sh @@ -7528,6 +7528,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" @@ -7545,7 +7554,7 @@ _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\"" @@ -7572,7 +7581,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 @@ -7584,7 +7593,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 @@ -7658,30 +7667,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 - fi + if [ "$?" != "0" ]; then + _err "Installation failed, cannot copy $PROJECT_ENTRY" + return 1 + fi - _info "Installed to $LE_WORKING_DIR/$PROJECT_ENTRY" + _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" + 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 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 @@ -7709,7 +7726,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")" @@ -7734,7 +7751,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 } @@ -7748,8 +7767,12 @@ uninstall() { _uninstallalias - rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY" - rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" + 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." } @@ -7785,20 +7808,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 @@ -8128,6 +8155,10 @@ Parameters: } 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" @@ -8185,6 +8216,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 From 97c5aca136c37830ffcd116435aa1eb9b2d80aa1 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 18 Jul 2026 09:32:26 +0800 Subject: [PATCH 201/224] add cache-after-prepare: true --- .github/workflows/DNS.yml | 11 +++++++++++ .github/workflows/DragonFlyBSD.yml | 1 + .github/workflows/FreeBSD.yml | 1 + .github/workflows/GhostBSD.yml | 1 + .github/workflows/Haiku.yml | 1 + .github/workflows/MidnightBSD.yml | 1 + .github/workflows/NetBSD.yml | 1 + .github/workflows/Omnios.yml | 1 + .github/workflows/OpenBSD.yml | 1 + .github/workflows/OpenIndiana.yml | 1 + .github/workflows/Solaris.yml | 1 + .github/workflows/Tribblix.yml | 1 + 12 files changed, 22 insertions(+) diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index 5417068f..f7d5cb98 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -237,6 +237,7 @@ jobs: - uses: vmactions/freebsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: pkg install -y socat curl usesh: true @@ -295,6 +296,7 @@ jobs: - uses: vmactions/ghostbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: pkg install -y socat curl usesh: true @@ -351,6 +353,7 @@ jobs: - uses: vmactions/openbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: pkg_add socat curl libiconv usesh: true @@ -407,6 +410,7 @@ jobs: - uses: vmactions/netbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: | /usr/sbin/pkg_add curl socat @@ -464,6 +468,7 @@ jobs: - uses: vmactions/dragonflybsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: | pkg install -y libnghttp2 @@ -525,6 +530,7 @@ jobs: - uses: vmactions/midnightbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: mport install socat curl || true usesh: true @@ -582,6 +588,7 @@ jobs: - uses: vmactions/solaris-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: nfs prepare: | @@ -641,6 +648,7 @@ jobs: - uses: vmactions/omnios-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: nfs prepare: pkg install socat @@ -697,6 +705,7 @@ jobs: - uses: vmactions/openindiana-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: nfs prepare: pkg install socat @@ -753,6 +762,7 @@ jobs: - uses: vmactions/tribblix-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: nfs prepare: zap install socat @@ -809,6 +819,7 @@ jobs: - uses: vmactions/haiku-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: rsync copyback: false diff --git a/.github/workflows/DragonFlyBSD.yml b/.github/workflows/DragonFlyBSD.yml index c8cbc985..20c61dcc 100644 --- a/.github/workflows/DragonFlyBSD.yml +++ b/.github/workflows/DragonFlyBSD.yml @@ -58,6 +58,7 @@ jobs: - uses: vmactions/dragonflybsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/FreeBSD.yml b/.github/workflows/FreeBSD.yml index 50fcab32..88ef0a6e 100644 --- a/.github/workflows/FreeBSD.yml +++ b/.github/workflows/FreeBSD.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/freebsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/GhostBSD.yml b/.github/workflows/GhostBSD.yml index c77fdf2e..04510c15 100644 --- a/.github/workflows/GhostBSD.yml +++ b/.github/workflows/GhostBSD.yml @@ -66,6 +66,7 @@ jobs: - uses: vmactions/ghostbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/Haiku.yml b/.github/workflows/Haiku.yml index 9884ebeb..b133dd18 100644 --- a/.github/workflows/Haiku.yml +++ b/.github/workflows/Haiku.yml @@ -65,6 +65,7 @@ jobs: - uses: vmactions/haiku-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/MidnightBSD.yml b/.github/workflows/MidnightBSD.yml index 15024833..ce499e4e 100644 --- a/.github/workflows/MidnightBSD.yml +++ b/.github/workflows/MidnightBSD.yml @@ -58,6 +58,7 @@ jobs: - uses: vmactions/midnightbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/NetBSD.yml b/.github/workflows/NetBSD.yml index 16d0ae2d..6695f71e 100644 --- a/.github/workflows/NetBSD.yml +++ b/.github/workflows/NetBSD.yml @@ -58,6 +58,7 @@ jobs: - uses: vmactions/netbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/Omnios.yml b/.github/workflows/Omnios.yml index eb486b35..aabb168b 100644 --- a/.github/workflows/Omnios.yml +++ b/.github/workflows/Omnios.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/omnios-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/OpenBSD.yml b/.github/workflows/OpenBSD.yml index 4fdb76c5..46318163 100644 --- a/.github/workflows/OpenBSD.yml +++ b/.github/workflows/OpenBSD.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/openbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/OpenIndiana.yml b/.github/workflows/OpenIndiana.yml index b5061ba7..e3119f8e 100644 --- a/.github/workflows/OpenIndiana.yml +++ b/.github/workflows/OpenIndiana.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/openindiana-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/Solaris.yml b/.github/workflows/Solaris.yml index 3393269a..30e4e291 100644 --- a/.github/workflows/Solaris.yml +++ b/.github/workflows/Solaris.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/solaris-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/Tribblix.yml b/.github/workflows/Tribblix.yml index cd43e0e3..68e61dc8 100644 --- a/.github/workflows/Tribblix.yml +++ b/.github/workflows/Tribblix.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/tribblix-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" From 6feb1df83cb2d1e628c0b751cf63a645ec5266c5 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 19 Jul 2026 10:20:58 +0800 Subject: [PATCH 202/224] fix cpanel_uapi: pass --user to DomainInfo list_domains when run as root The auto mode sitelist query was missing the --user branch that the install_ssl calls already have, so deploy always failed under root. fix https://github.com/acmesh-official/acme.sh/issues/7139 --- deploy/cpanel_uapi.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/deploy/cpanel_uapi.sh b/deploy/cpanel_uapi.sh index 156044eb..02ef6b3e 100644 --- a/deploy/cpanel_uapi.sh +++ b/deploy/cpanel_uapi.sh @@ -87,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:" From 4c8a143086549d77d21fcec4fce0bb7d97b60821 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 20 Jul 2026 10:02:54 +0800 Subject: [PATCH 203/224] fix proxmoxve/proxmoxbs deploy: fail on non-2xx API response The success check only grepped "message" from the response body, but PVE/PBS auth failures return HTTP 401 with an empty body, so wrong or unauthorized API tokens were reported as "Certificate successfully deployed". Also _retval captured the exit code of the message pipeline instead of _post. Check the HTTP status line from $HTTP_HEADER and capture _post's exit code directly. fix https://github.com/acmesh-official/acme.sh/issues/7141 --- deploy/proxmoxbs.sh | 27 +++++++++++++++++---------- deploy/proxmoxve.sh | 27 +++++++++++++++++---------- 2 files changed, 34 insertions(+), 20 deletions(-) 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 } From 1774d838cabca07d50e55b7e736c83e3d1228ecf Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 22 Jul 2026 20:42:14 +0800 Subject: [PATCH 204/224] add GNU hurd --- .github/workflows/DNS.yml | 91 +++++++++++++++++++++++++++++++------- .github/workflows/Hurd.yml | 76 +++++++++++++++++++++++++++++++ README.md | 2 + 3 files changed, 154 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/Hurd.yml diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index f7d5cb98..09b65a96 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -66,7 +66,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - name: Set env file @@ -114,7 +114,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install tools run: | brew untap aws/tap || true @@ -167,7 +167,7 @@ jobs: - name: Set git to use LF run: | git config --global core.autocrlf false - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install cygwin base packages with chocolatey run: | choco config get cacheLocation @@ -231,7 +231,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/freebsd-vm@v1 @@ -290,7 +290,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/ghostbsd-vm@v1 @@ -347,7 +347,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/openbsd-vm@v1 @@ -404,7 +404,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/netbsd-vm@v1 @@ -462,7 +462,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/dragonflybsd-vm@v1 @@ -524,7 +524,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/midnightbsd-vm@v1 @@ -582,7 +582,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/solaris-vm@v1 @@ -642,7 +642,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/omnios-vm@v1 @@ -699,7 +699,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/openindiana-vm@v1 @@ -756,7 +756,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/tribblix-vm@v1 @@ -813,7 +813,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/haiku-vm@v1 @@ -826,7 +826,68 @@ jobs: prepare: | mkdir -p /boot/home/.cache pkgman install -y cronie - + + run: | + if [ "${{ secrets.TokenName1}}" ] ; then + export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" + fi + if [ "${{ secrets.TokenName2}}" ] ; then + export ${{ secrets.TokenName2}}="${{ secrets.TokenValue2}}" + fi + if [ "${{ secrets.TokenName3}}" ] ; then + export ${{ secrets.TokenName3}}="${{ secrets.TokenValue3}}" + fi + if [ "${{ secrets.TokenName4}}" ] ; then + export ${{ secrets.TokenName4}}="${{ secrets.TokenValue4}}" + fi + if [ "${{ secrets.TokenName5}}" ] ; then + export ${{ secrets.TokenName5}}="${{ secrets.TokenValue5}}" + fi + cd ../acmetest + ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" + + + + Hurd: + runs-on: ubuntu-latest + needs: Haiku + env: + TEST_DNS : ${{ secrets.TEST_DNS }} + TestingDomain: ${{ secrets.TestingDomain }} + TEST_DNS_NO_WILDCARD: ${{ secrets.TEST_DNS_NO_WILDCARD }} + TEST_DNS_NO_SUBDOMAIN: ${{ secrets.TEST_DNS_NO_SUBDOMAIN }} + TEST_DNS_SLEEP: ${{ secrets.TEST_DNS_SLEEP }} + CASE: le_test_dnsapi + TEST_LOCAL: 1 + DEBUG: ${{ secrets.DEBUG }} + http_proxy: ${{ secrets.http_proxy }} + https_proxy: ${{ secrets.https_proxy }} + HTTPS_INSECURE: 1 # always set to 1 to ignore https error + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v7 + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/hurd-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + sync: rsync + copyback: false + usesh: true + prepare: | + apt-get update -y + apt-get install -y curl cron run: | if [ "${{ secrets.TokenName1}}" ] ; then export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" diff --git a/.github/workflows/Hurd.yml b/.github/workflows/Hurd.yml new file mode 100644 index 00000000..fee80d29 --- /dev/null +++ b/.github/workflows/Hurd.yml @@ -0,0 +1,76 @@ +name: Hurd +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/Hurd.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Hurd.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + Hurd: + strategy: + matrix: + include: + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + runs-on: ubuntu-latest + env: + TEST_LOCAL: 1 + TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }} + CA_ECDSA: ${{ matrix.CA_ECDSA }} + CA: ${{ matrix.CA }} + CA_EMAIL: ${{ matrix.CA_EMAIL }} + TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }} + steps: + - uses: actions/checkout@v7 + - uses: anyvm-org/cf-tunnel@v0 + id: tunnel + with: + protocol: http + port: 8080 + - name: Set envs + run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/hurd-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN' + nat: | + "8080": "80" + # Do NOT install socat: socat's SYSTEM: address is broken on GNU Hurd + # (the child shell output goes to socat's stdout instead of the socket, + # so clients get an empty reply). Without socat, acme.sh standalone + # mode falls back to its python3 server, which works on Hurd. + prepare: | + apt-get update -y + apt-get install -y curl cron + usesh: true + sync: rsync + copyback: false + run: | + cd ../acmetest \ + && ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/README.md b/README.md index b93a8a50..23c8b3e0 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ OpenIndiana Tribblix Haiku + Hurd

@@ -130,6 +131,7 @@ |25|[![Haiku](https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml)|Haiku OS |26|[![Tribblix](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml)|Tribblix |27|[![GhostBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml)|GhostBSD +|28|[![Hurd](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml)|GNU Hurd > 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest) From 24d573b6d3f0ee409c145b0ed26813efaae4c63c Mon Sep 17 00:00:00 2001 From: Radu Date: Thu, 23 Jul 2026 06:50:20 +0300 Subject: [PATCH 205/224] Merge pull request #7140 from radumalica/dns_hestiacp feat: add dnsapi for HestiaCP --- dnsapi/dns_hestiacp.sh | 198 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 dnsapi/dns_hestiacp.sh 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 < Date: Fri, 24 Jul 2026 07:55:17 +0200 Subject: [PATCH 206/224] feat: added realtoxmedia dnsapi (#7156) --- dnsapi/dns_rltx.sh | 145 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 dnsapi/dns_rltx.sh 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 +} From 698f6c73298e76c65ad7e726e462b50af1c5cf28 Mon Sep 17 00:00:00 2001 From: Qhilm <3350433+Qhilm@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:03:56 +0200 Subject: [PATCH 207/224] Feat: Shelly deploy hook for firmware 2.0.0+ (#7145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Shelly Gen3+ deploy hook with RFC 7616 HTTP Digest auth Adds deploy/shelly.sh for deploying Let's Encrypt HTTPS server certificates to Shelly Gen3+ devices (Gen4 tested) via JSON-RPC over HTTP. - RFC 7616 SHA-256 HTTP Digest authentication (Authorization header) - Uploads fullchain.pem and private key via Shelly.PutHTTPServerCert / PutHTTPServerKey - Auto-reboot support (SHELLY_REBOOT to disable) - Auth auto-detection: no password = no auth, password = Digest - Nonce counter (nc) increments per request per RFC 7616 - Tested against Shelly 2PM Gen4 (firmware 2.0.0) Also adds deploy/test_shelly.sh for self-testing the hook logic without a real device (mocked _post). * fix: address review feedback on shelly deploy hook - Fix _secure_debug calls to use two arguments (label + value) - Remove bash-only $RANDOM cnonce fallback; openssl always available - Parse $HTTP_HEADER directly instead of raw curl re-request - Detect auth via HTTP 401 status line, not empty response body - Route reboot through _shelly_rpc to rebuild auth header with correct nc - Remove export HTTPS_INSECURE=1 (no-op for http://, leaks to other hooks) - Clear _H1 before returning from shelly_deploy - Prefix all helper variables with _shelly_ to avoid namespace collisions - Delete deploy/test_shelly.sh (deploy/ files become hook names) - Fix missing trailing newline * fix: validate shelly JSON-RPC responses are valid JSON Non-JSON responses like HTTP 429 'Too Many Requests' would pass the empty-response and '"error"' checks and be reported as success. Now reject any response that doesn't start with '{' and contain '"id"'. * fix: add 1s delay between shelly cert/key clear and upload calls The Shelly device has a race condition where uploading data immediately after clearing the existing cert/key returns -103 'Missing required argument data!'. A 1-second delay fixes this. * fix: remove clear-before-upload in shelly deploy hook Shelly auto-removes all three TLS files (cert, key, CA bundle) when any single one is cleared. The old sequence clear-cert → upload-cert → clear-key → upload-key resulted in the key clear wiping the newly uploaded cert, leaving only the key at boot time. The mbedtls pk_check_pair then silently skipped the HTTPS listener. Fix: just upload directly (overwrite in place). No clearing needed. * Fix ShellCheck SC2090 and shfmt in shelly deploy hook SC2090: false positive on export _H1 (used quoted in _post) shfmt: no space after "<" in _json_encode redirects * moved two lines to cover the whole if block --------- Co-authored-by: neil Co-authored-by: cysimons --- deploy/shelly.sh | 280 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 deploy/shelly.sh 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 +} From 830782fd1da6b20f25017f9778af6fe1cc80d7a5 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 25 Jul 2026 13:33:54 +0800 Subject: [PATCH 208/224] fix dns_yc: avoid empty-matchable _egrep_o pattern that hangs OmniOS OmniOS native egrep -o infinite-loops emitting empty lines when the pattern can match the empty string, so `_egrep_o "[^:]*$"` never lets the pipeline finish and dns_yc hangs until the CI timeout. Require at least one character instead. `+` is not usable because the sed fallback in _egrep_o parses BRE. --- dnsapi/dns_yc.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index 36c49ce4..13a0d1f6 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -126,7 +126,7 @@ dns_yc_rm() { _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 "[^:]*$") + exists_txtvalue=$(echo "$response" | _normalizeJson | _egrep_o "\"data\".*\][^,]*" | _egrep_o "[^:][^:]*$") _debug exists_txtvalue "$exists_txtvalue" else _err "Error: $response" @@ -194,7 +194,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") @@ -264,7 +264,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 From 7c12deb7ef2e8c5fdcfced93aaa5501ae30e4d35 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 25 Jul 2026 16:00:03 +0800 Subject: [PATCH 209/224] fix: grep -A is not portable, breaks ARI on Solaris Solaris /usr/bin/grep has no -A ("illegal option -- A"), so _getAKI printed an error to stderr on every cron renewal and returned empty. The empty AKI silently corrupts the RFC 9773 ARI certID, so ARI is never available and renewal falls back to the fixed schedule. Split the pipeline into a testable stdin filter _extractAKI and select the value line with a portable sed range instead. Same fix for the two hooks that still used grep -A: dns_world4you.sh (also replaces the GNU-only "\s" in the same expression) and deploy/keyhelp.sh (the -A 2 window could truncate the div range that follows it, so it is just dropped). https://github.com/acmesh-official/acme.sh/issues/7159 --- acme.sh | 10 +++++++++- deploy/keyhelp.sh | 4 ++-- dnsapi/dns_world4you.sh | 15 +++++++++++++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/acme.sh b/acme.sh index 274af3ac..17244133 100755 --- a/acme.sh +++ b/acme.sh @@ -7369,10 +7369,18 @@ 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" - ${ACME_OPENSSL_BIN:-openssl} x509 -in "$_cert" -text -noout | grep -A 1 "X509v3 Authority Key Identifier" | _tail_n 1 | tr -d ': ' | sed "s/keyid//" + ${ACME_OPENSSL_BIN:-openssl} x509 -in "$_cert" -text -noout | _extractAKI } #cert 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/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)}" From 057c94089595b9565bc350ac9ceef73d7e3b9784 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 26 Jul 2026 15:01:41 +0800 Subject: [PATCH 210/224] add OpenEuler --- .github/workflows/DNS.yml | 59 +++++++++++++++++++++++++++ .github/workflows/OpenEuler.yml | 70 +++++++++++++++++++++++++++++++++ README.md | 2 + 3 files changed, 131 insertions(+) create mode 100644 .github/workflows/OpenEuler.yml diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index 09b65a96..84a17470 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -914,3 +914,62 @@ jobs: + OpenEuler: + runs-on: ubuntu-latest + needs: Hurd + env: + TEST_DNS : ${{ secrets.TEST_DNS }} + TestingDomain: ${{ secrets.TestingDomain }} + TEST_DNS_NO_WILDCARD: ${{ secrets.TEST_DNS_NO_WILDCARD }} + TEST_DNS_NO_SUBDOMAIN: ${{ secrets.TEST_DNS_NO_SUBDOMAIN }} + TEST_DNS_SLEEP: ${{ secrets.TEST_DNS_SLEEP }} + CASE: le_test_dnsapi + TEST_LOCAL: 1 + DEBUG: ${{ secrets.DEBUG }} + http_proxy: ${{ secrets.http_proxy }} + https_proxy: ${{ secrets.https_proxy }} + HTTPS_INSECURE: 1 # always set to 1 to ignore https error + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v7 + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/openeuler-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + sync: rsync + copyback: false + usesh: true + prepare: dnf install -y curl socat cronie tar gzip + run: | + if [ "${{ secrets.TokenName1}}" ] ; then + export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" + fi + if [ "${{ secrets.TokenName2}}" ] ; then + export ${{ secrets.TokenName2}}="${{ secrets.TokenValue2}}" + fi + if [ "${{ secrets.TokenName3}}" ] ; then + export ${{ secrets.TokenName3}}="${{ secrets.TokenValue3}}" + fi + if [ "${{ secrets.TokenName4}}" ] ; then + export ${{ secrets.TokenName4}}="${{ secrets.TokenValue4}}" + fi + if [ "${{ secrets.TokenName5}}" ] ; then + export ${{ secrets.TokenName5}}="${{ secrets.TokenValue5}}" + fi + cd ../acmetest + ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" + + + diff --git a/.github/workflows/OpenEuler.yml b/.github/workflows/OpenEuler.yml new file mode 100644 index 00000000..2b4bd0ab --- /dev/null +++ b/.github/workflows/OpenEuler.yml @@ -0,0 +1,70 @@ +name: OpenEuler +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/OpenEuler.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/OpenEuler.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + OpenEuler: + strategy: + matrix: + include: + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + runs-on: ubuntu-latest + env: + TEST_LOCAL: 1 + TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }} + CA_ECDSA: ${{ matrix.CA_ECDSA }} + CA: ${{ matrix.CA }} + CA_EMAIL: ${{ matrix.CA_EMAIL }} + TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }} + steps: + - uses: actions/checkout@v7 + - uses: anyvm-org/cf-tunnel@v0 + id: tunnel + with: + protocol: http + port: 8080 + - name: Set envs + run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/openeuler-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN' + nat: | + "8080": "80" + prepare: dnf install -y curl socat cronie tar gzip + usesh: true + sync: rsync + copyback: false + run: | + cd ../acmetest \ + && ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/README.md b/README.md index 23c8b3e0..90280e94 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Tribblix Haiku Hurd + OpenEuler

@@ -132,6 +133,7 @@ |26|[![Tribblix](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml)|Tribblix |27|[![GhostBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml)|GhostBSD |28|[![Hurd](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml)|GNU Hurd +|29|[![OpenEuler](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml)|openEuler > 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest) From 05654436229074ff015a6539476eb51dd8b8609c Mon Sep 17 00:00:00 2001 From: Goncharenko Alexander <61147910+gasRU76@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:04:20 +0300 Subject: [PATCH 211/224] dns_yc: fix TXT record removal failing with "Unknown key file format" (#7150) * dns_yc: restore YC_SA_Key_File in dns_yc_rm before signing the JWT dns_yc_rm() never rebuilt YC_SA_Key_File from YC_SA_Key_File_PEM_b64 / YC_SA_Key_File_Path like dns_yc_add() does. Per the DNS API dev guide, add()/rm() run in separate subshells, so rm() must repeat add()'s setup steps rather than rely on variables set during add(). Without it, when _yc_login() needs a fresh JWT during removal (the IAM token from the add phase isn't available), it signs with an empty/unset key path, and openssl fails with "Unknown key file format". The resulting auth failure then surfaces misleadingly as "invalid domain" in _get_root, and the TXT record is never deleted. Verified against a real Yandex Cloud account/zone with --staging: before the fix, removal failed with the same errors reported in the issue; after adding the missing key-restoration block, add + remove both succeed and the TXT record is actually deleted. * dns_yc: preserve other TXT values when removing one at the same name dns_yc_rm previously sent the full current data array (all existing TXT values at the name) to the deletions API, wiping out the whole rrset instead of only the value being removed. This breaks wildcard + base domain issuance, where both share the same _acme-challenge name with two different values: removing the first one deleted both, leaving nothing for the second removal to find. * dns_yc: read persisted config from domain conf before account conf YC_Zone_ID, YC_Folder_ID, YC_SA_ID, YC_SA_Key_ID (zone-ID mode) and YC_SA_Key_File_PEM_b64/Path were always saved via _savedomainconf (domain.conf), but only ever read back via _readaccountconf_mutable (account.conf). Once the env vars were unset, none of these could be recovered from the saved config, so dns_yc_add/dns_yc_rm failed with "You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File." even though the values had been persisted correctly on the prior run. * dns_yc: replace grep -Fxv/sed with a portable loop in dns_yc_rm Solaris's /usr/bin/grep supports neither -F nor -x, so _remaining_txtvalue was always empty there and the preserve-other- values logic silently fell back to deleting the whole rrset (with a grep usage error on stderr on every rm). The sed trailing-comma strip had a matching issue on Solaris, whose sed drops an unterminated last line. CI didn't catch this because the fallback path also returns "done: true". Use a plain for-loop with word splitting instead. * dns_yc: use upsertRecordSets.deletions to remove a single TXT value updateRecordSets has no "merges" field (only deletions/additions), so the previous preserve-other-values logic silently did nothing -- the TXT record was never actually removed, a regression from before that change (which at least deleted the whole rrset). CI didn't catch it because _clearupdns runs dns_yc_rm in a subshell and ignores its exit code. upsertRecordSets.deletions removes only the specified value from the rrset directly, so the getRecordSet read and the remaining-value recomputation are no longer needed at all. Verified against a real zone (base + wildcard domain sharing one _acme-challenge name): adding both values then removing one leaves the other in place, and removing the second cleans up fully. * dns_yc: don't delete the user's own key file in YC_SA_Key_File_Path mode _yc_login unconditionally rm'd $YC_SA_Key_File after signing. That's fine for the PEM_b64 path, where it's a decoded temp file, but in YC_SA_Key_File_Path mode it's the user's own persistent key file -- the first successful login permanently deleted it, so every subsequent dns_yc_rm/renewal hit "Unknown key file format" (the exact symptom this PR is about, just from a different cause). Track whether the key file is our own temp copy and only delete it in that case. Verified with a stubbed _yc_login: a temp-mode key gets removed after login, a path-mode key survives. * dns_yc: clear both domain and account conf on invalid config The failure branch in dns_yc_add only ever called _clearaccountconf, but YC_Zone_ID/YC_Folder_ID/YC_SA_Key_File_PEM_b64/Path are persisted via _savedomainconf, and YC_SA_ID/YC_SA_Key_ID may have been saved via _saveaccountconf_mutable (Folder_ID mode, which stores under a SAVED_ prefix read back by _readaccountconf_mutable). Clearing only one store left stale values behind in whichever one wasn't touched. Verified by seeding both domain.conf and account.conf with leftover values, then triggering this branch and confirming both config files end up empty. --- dnsapi/dns_yc.sh | 62 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index 13a0d1f6..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 @@ -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" From bf90b845b2d673adcf2b132826eb975fe41e70f2 Mon Sep 17 00:00:00 2001 From: Joel Samson Date: Sun, 2 Aug 2026 09:34:41 -0400 Subject: [PATCH 212/224] Refactor dns_freemyip.sh for enhanced compatibility (#7166) * Refactor dns_freemyip.sh for clarity and compatibility Updated dns_freemyip.sh for better readability and compatibility with ASUSWRT-Merlin. Improved error handling and response logging. * Update author information in dns_freemyip.sh * replace both loops with POSIX shell counters replace both loops with POSIX shell counters * Typo Typo * Fix error message for freemyip API request failure Remove existing token leak. Not my regression. * Refactor retry logic and improve error handling * Remove unnecessary blank lines in dns_freemyip.sh * Clean up dns_freemyip.sh by removing blank lines Removed unnecessary blank lines in the script to improve readability. --- dnsapi/dns_freemyip.sh | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) 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 } From ea5e70564d94ec3aa4a06c72b33e458f0c8fcb91 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 2 Aug 2026 21:47:51 +0800 Subject: [PATCH 213/224] Fix --make-dns-persist-value printing a wildcard TXT record name For -d '*.example.com' the printed record name kept the literal '*' label (_validation-persist.*.example.com). The CA never queries that name, so issuance fails with "No TXT record found for DNS-PERSIST-01 challenge". Per draft-ietf-acme-dns-persist-01 sec 4 and 10.2 the record is published at the base domain's Validation Domain Name; the wildcard scope comes from 'policy=wildcard' in the record value (sec 5.1), not from a '*' label in the record name. Strip the leading "*." in a new _dns_persist_txt_name helper, and imply --dns-persist-wildcard for a wildcard -d, since without policy=wildcard the printed record can never authorize the wildcard. Fixes #7168 --- acme.sh | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index 17244133..9601d43d 100755 --- a/acme.sh +++ b/acme.sh @@ -4355,6 +4355,24 @@ 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. @@ -4369,6 +4387,20 @@ makednspersistvalue() { 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]*) @@ -4400,8 +4432,6 @@ makednspersistvalue() { fi _debug "Account URL" "$_accUri" - _txt_name="_validation-persist.$_mdpv_domain" - _txt_suffix="; accounturi=$_accUri" if [ "$_mdpv_wildcard" = "1" ]; then _txt_suffix="$_txt_suffix; policy=wildcard" @@ -8075,7 +8105,9 @@ Parameters: --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). + 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 From b4925052dd10f6b407fba5c5f550ebe829356d28 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 2 Aug 2026 21:55:19 +0800 Subject: [PATCH 214/224] Fix dns_cyon cleanup failing on FreeBSD _cyon_delete_txt relied on `printf "%b"` to convert a sed-injected literal `\n` into a real newline, but `%b` also processes the `\"` escapes that the JSON response is full of. glibc/bash/dash keep the backslash of such an undefined escape, FreeBSD's printf (sh builtin and /usr/bin/printf alike) drops it -- so `data-hash=\"..\"` became `data-hash=".."`, the extraction regex matched nothing, _dns_entries stayed empty and no TXT record was ever deleted. Drop the newline injection and use _egrep_o, which already yields one match per line, then parse each line with sed. Also feed the read loop a newline-terminated list: `printf "%s"` left the last line unterminated, so `read` returned non-zero at EOF and the loop skipped the final entry on every platform. Verified identical output on FreeBSD 14.3, Linux/bash and Linux/dash. Fixes #7169 --- dnsapi/dns_cyon.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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)" From 5e6c263211d05d75434f10e61ac479b06ff30a1f Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 5 Aug 2026 19:53:52 +0800 Subject: [PATCH 215/224] Fix empty finalize URL when resuming a saved DNS-manual order The decision to resume a pending order is keyed on Le_Vlist, but the decision to keep Le_OrderFinalize/Le_LinkOrder was keyed on the webroot being exactly "dns". Any other webroot with a saved Le_Vlist skipped newOrder and then finalized against an empty URL. Key both on Le_Vlist, and always clear Le_LinkCert, which is per-run state that is never read back from the saved domain conf. Fixes #7177 --- acme.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index 9601d43d..5b59ece4 100755 --- a/acme.sh +++ b/acme.sh @@ -4949,11 +4949,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" From 603a126a7cd557d001dfc903ddd0189c914262e6 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 5 Aug 2026 19:56:13 +0800 Subject: [PATCH 216/224] Fix synology_dsm logging out after the temp admin is already deleted _temp_admin_cleanup ran before _logout, so the logout request carried the session id of an account synouser had already removed and DSM kept the orphaned entry in Connected Users. Swap the order in both terminal branches, and add the missing _logout to the two post-login error paths (CRT list failure, certificate not found without SYNO_CREATE). _logout overwrites the global $response, so the upload-failure branch prints its error message before calling it. Reported by @Bertl75 in #7174 --- deploy/synology_dsm.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index d05e503a..336980a5 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -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" } From 9aad4dcbd5fb22f4c97ee72963af58ee25481d1b Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 5 Aug 2026 23:01:54 +0800 Subject: [PATCH 217/224] Fix multideploy MULTIDEPLOY_FILENAME conf read and allow an absolute path _getdeployconf assigns and exports the variable, it does not print the value, so wrapping it in a command substitution ran it in a subshell and always yielded an empty string. A MULTIDEPLOY_FILENAME saved by an earlier run was therefore never restored on renewal and the hook silently fell back to multideploy.yml. Call it the same way every other deploy hook does. Also treat a MULTIDEPLOY_FILENAME starting with '/' as an absolute path instead of always resolving it under DOMAIN_PATH, so one deploy file can live outside the certificate directory and be shared by all domains. Names without a leading '/' keep resolving under DOMAIN_PATH as before. --- deploy/multideploy.sh | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/deploy/multideploy.sh b/deploy/multideploy.sh index 375668ec..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: From f67be78ff429df2ebd7c447cfefd463804ddb4b6 Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 6 Aug 2026 19:36:26 +0800 Subject: [PATCH 218/224] Fix dns_namecheap ignoring IsOurDNS when matching the root zone _get_root_by_getList() matched the candidate suffix as an unanchored substring of the whole domains.getList response and never looked at the IsOurDNS attribute. A domain parked on Namecheap's webhosting DNS is listed with IsOurDNS="false", yet it was still accepted as the root zone, so _get_root() returned success and the domains.dns.getHosts probe that would have found the real zone never ran. Every following getHosts call was then refused with error 2030288 "not using proper DNS servers" and the challenge failed with "invalid tld". Match the exact entry instead and require IsOurDNS="true", so a subdomain delegated to Namecheap BasicDNS/FreeDNS under a parent that is not on Namecheap DNS now resolves to its own zone. Matching the entry exactly also drops the old substring/regex match, in which the dots of a domain matched any character. Fixes #7178 --- dnsapi/dns_namecheap.sh | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/dnsapi/dns_namecheap.sh b/dnsapi/dns_namecheap.sh index cca59735..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 From f1cbba05f67e82f921990d385add7db17224d56c Mon Sep 17 00:00:00 2001 From: Alexey Morozov <223741939+morozov-alexey@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:22:28 +0200 Subject: [PATCH 219/224] feat: added nexdns dnsapi (#7170) * feat: added nexdns dnsapi Adds a DNS-01 hook for NexDNS, an authoritative DNS service with a REST API. dns_nexdns_add walks the label list to find the zone that owns the challenge name and creates the TXT record in it. dns_nexdns_rm lists the TXT records at that name, picks the one carrying exactly this challenge value and deletes it by id, so a wildcard and its base domain do not remove each other's record. A 429 is waited out and the request retried, in the shape dns_hetznercloud.sh and dns_bunny.sh already use. * dns_nexdns: cap the rate-limit wait, judge success by status, add the tracking issue --- dnsapi/dns_nexdns.sh | 244 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100755 dnsapi/dns_nexdns.sh 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 +} From 05367d3598b43618f7e89f160f5d570f2ab1da39 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 8 Aug 2026 13:09:18 +0800 Subject: [PATCH 220/224] Listen on both IPv4 and IPv6 in standalone mode by default socat binds a single family unless told which one: up to 1.7.x the default IP version for TCP-LISTEN is 4, and 1.8.0 made it "no preference", which resolves to whatever getaddrinfo and bindv6only happen to give. So an order carrying both an IPv4 and an IPv6 identifier could never pass both http-01 challenges. Bind one socket per family instead, with ipv6only on the IPv6 one so the two do not collide. IPv4-mapped IPv6 addresses are not a portable alternative, OpenBSD does not support them at all. The IPv6 listener is best effort, a host without IPv6 still gets the IPv4 one. The python fallback does the same. --listen-v4 and --listen-v6 keep forcing a single family, and passing both now means both. Le_Listen_V4 and Le_Listen_V6 were mutually exclusive in the domain conf, which silently dropped one of them on renewal, and _starttlsserver let -4 win when both were set. Fixes #7185 --- acme.sh | 115 ++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 87 insertions(+), 28 deletions(-) diff --git a/acme.sh b/acme.sh index 5b59ece4..4d4f6cbe 100755 --- a/acme.sh +++ b/acme.sh @@ -2715,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" @@ -2728,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 @@ -2745,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 @@ -2753,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 @@ -2772,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 @@ -2809,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 @@ -2882,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 @@ -5986,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 @@ -8172,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. From 4a3bc2c9193360bc1a8fbf7e0d7974c8e3d0a2f1 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 10:00:26 +0800 Subject: [PATCH 221/224] Fix dns_netcup reporting a bogus 4013 instead of the real zone error The zone lookup walked the challenge name from the right and ended up asking netcup for the full "_acme-challenge." as a zone name. That can never be a zone, so netcup answered 4013 "Validation Error", which replaced the real 5028 "The zone could not be found" as the error shown to the user. Stop one label short of the full name, and fail explicitly when no zone matched, reporting the last API response plus what to check. Before, a run where every candidate returned 5028 fell through to logout and returned success. --- dnsapi/dns_netcup.sh | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) 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) From 2e2782f0d87b620acb1b17935ab658b4b904788c Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Wed, 12 Aug 2026 10:47:58 -0400 Subject: [PATCH 222/224] Remove deprecated Linode API v3 DNS plugin (#7054) Signed-off-by: Zhiwei Liang --- dnsapi/dns_linode.sh | 189 ------------------------------------------- 1 file changed, 189 deletions(-) delete mode 100755 dnsapi/dns_linode.sh 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 -} From a89ba9c2e5ca269047adf585a371bd7bd3e25587 Mon Sep 17 00:00:00 2001 From: XuChao Date: Thu, 13 Aug 2026 14:52:48 +0800 Subject: [PATCH 223/224] add deploy hook support for ikuai (#6456) * add deploy hook support for ikuai * fix shellcheck warn and shfmt the code * 1.fix config load 2.use _secre_debug2 to log password 3.use fullchain to deploy 4.fix hardcode id 5.change shebang * fix miss ; after cookie * 1.fix shfmt ; 2.fix IKUAI_CERT_ID conf load; 3.correct IKUAI_CERT_ID description * fix some log msg * fix shfmt --- deploy/ikuai.sh | 114 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 deploy/ikuai.sh diff --git a/deploy/ikuai.sh b/deploy/ikuai.sh new file mode 100644 index 00000000..fa0926dc --- /dev/null +++ b/deploy/ikuai.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env sh + +# Here is a script to deploy cert to ikuai using curl +# +# it requires following environment variables: +# +# IKUAI_SCHEME="http" - http or https , defaults to "http" +# IKUAI_HOSTNAME="localhost" - host , defaults to "192.168.9.1" +# IKUAI_PORT="80" - port , defaults to "80" +# IKUAI_USERNAME="admin" - username , defaults to "admin" +# IKUAI_PASSWORD="yourPassword" - password +# IKUAI_CERT_ID=1 - ikuai cert id , defaults to 1, and only 1 is supported for now !!! +# +#returns 0 means success, otherwise error. +# +######## Public functions ##################### +# +#domain keyfile certfile cafile fullchain +ikuai_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" + + # Get deploy conf + _getdeployconf IKUAI_SCHEME + _getdeployconf IKUAI_HOSTNAME + _getdeployconf IKUAI_PORT + _getdeployconf IKUAI_USERNAME + _getdeployconf IKUAI_PASSWORD + _getdeployconf IKUAI_CERT_ID + + # Use default if not provided + [ -n "$IKUAI_SCHEME" ] || IKUAI_SCHEME="http" + [ -n "$IKUAI_HOSTNAME" ] || IKUAI_HOSTNAME="192.168.9.1" + [ -n "$IKUAI_PORT" ] || IKUAI_PORT=80 + [ -n "$IKUAI_USERNAME" ] || IKUAI_USERNAME="admin" + [ -n "$IKUAI_CERT_ID" ] || IKUAI_CERT_ID=1 + + if [ -z "$IKUAI_PASSWORD" ]; then + _err "please define IKUAI_PASSWORD." + return 1 + fi + + _debug2 IKUAI_SCHEME "$IKUAI_SCHEME" + _debug2 IKUAI_HOSTNAME "$IKUAI_HOSTNAME" + _debug2 IKUAI_PORT "$IKUAI_PORT" + _debug2 IKUAI_USERNAME "$IKUAI_USERNAME" + _secure_debug2 IKUAI_PASSWORD "$IKUAI_PASSWORD" + + _info "Login to ikuai ..." + _ikuai_url="$IKUAI_SCHEME://$IKUAI_HOSTNAME:$IKUAI_PORT" + _pass_md5="$(printf "%s" "$IKUAI_PASSWORD" | _digest md5 hex | _lower_case)" + _pass_salt="$(printf "salt_11%s" "$IKUAI_PASSWORD" | _base64)" + _debug2 _ikuai_url "$_ikuai_url" + + _login_req="{\"username\":\"$IKUAI_USERNAME\",\"passwd\":\"$_pass_md5\",\"pass\":\"$_pass_salt\",\"remember_password\":\"\"}" + _response=$(_post "$_login_req" "$_ikuai_url/Action/login" "" "POST" "application/json") + + _err_msg="$(printf "%s" "$_response" | _normalizeJson | _egrep_o '"ErrMsg":"[^"]*"' | cut -d'"' -f 4)" + # check ErrMsg + if [ "$_err_msg" != "Success" ]; then + _err "Failed to login to ikuai: $_err_msg" + return 1 + fi + # check cookie + _cookie="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _head_n 1 | cut -d " " -f 2 | sed 's/;.*//')" + if [ -z "$_cookie" ]; then + _err "Fail to get the cookie." + return 1 + fi + + # Set cookie header + _H1="Cookie: $_cookie; username=$IKUAI_USERNAME; login=1" + + _info "Deploy the cert to ikuai ... " + + # Should replace \n to @ ," " to # + _cert_content_single_line="$(tr <"$_cfullchain" '\n' '@' | tr ' ' '#')" + _key_content_single_line="$(tr <"$_ckey" '\n' '@' | tr ' ' '#')" + + _debug2 _cert_content_single_line "$_cert_content_single_line" + _secure_debug2 _key_content_single_line "$_key_content_single_line" + + _key_manager_req="{\"func_name\":\"key_manager\",\"action\":\"save\",\"param\":{\"ca\":\"$_cert_content_single_line\",\"key\":\"$_key_content_single_line\",\"id\":$IKUAI_CERT_ID,\"enabled\":\"yes\",\"comment\":\"\"}}" + _response=$(_post "$_key_manager_req" "$_ikuai_url/Action/call" "" "POST" "application/json") + + _err_msg="$(printf "%s" "$_response" | _normalizeJson | _egrep_o '"ErrMsg":"[^"]*"' | cut -d'"' -f 4)" + # check ErrMsg + if [ "$_err_msg" != "Success" ]; then + _err "Failed to deploy the cert to ikuai: $_err_msg" + return 1 + fi + + _info "Save the deploy config ... " + # Save the config + _savedeployconf IKUAI_SCHEME "$IKUAI_SCHEME" + _savedeployconf IKUAI_HOSTNAME "$IKUAI_HOSTNAME" + _savedeployconf IKUAI_PORT "$IKUAI_PORT" + _savedeployconf IKUAI_USERNAME "$IKUAI_USERNAME" + _savedeployconf IKUAI_PASSWORD "$IKUAI_PASSWORD" + _savedeployconf IKUAI_CERT_ID "$IKUAI_CERT_ID" + + _info "Successfully deployed certificate to ikuai. Enjoy! :>" + + return 0 +} From 41bdd4cd0e9bc908fa57dcfb9f8afc3c2658af40 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 12 Aug 2026 23:57:44 -0700 Subject: [PATCH 224/224] Add UniFi OS Server deploy hook (#7184) * Add UniFi OS Server deploy hook Uses UniFi OS Server's local REST API (login, list, upload, activate, remove superseded) since it stores certificates in its own Postgres database rather than flat config files, unlike the Cloud Key/UDM hardware covered by the existing unifi deploy hook. Tested against real instances on both macOS and Ubuntu 26.04 (self-hosted, remote). * Address review: portable sed/grep, scoped HTTPS_INSECURE, fingerprint matching - Replace GNU-only \n in sed replacement with a portable literal newline (matches dnsapi/dns_cpanel_uapi.sh, dnsapi/dns_glesys.sh); pipe the list response through _normalizeJson first for consistent formatting. - Use grep -F for the domain-name match instead of an unescaped BRE -- a wildcard cert name (*.example.com) broke the regex. - Drop \W (undocumented, GNU-only) from the cookie lookup in favor of an anchored `^Set-Cookie: *NAME=` match. - Scope HTTPS_INSECURE=1 inside the hook (matches deploy/proxmoxve.sh, deploy/fritzbox.sh) instead of requiring the caller to export it for the whole acme.sh run, which would also disable verification for the connection to the ACME CA. - On a duplicate-certificate response, match the existing entry by fingerprint instead of taking the first name match -- with more than one stale entry for a domain, the wrong one could get activated. - Check the list endpoint's response code before proceeding. - Save username/password with the "base64" flag (matches deploy/synology_dsm.sh) since _save_conf wraps values in unescaped single quotes. * Rework certificate handling: unique names per upload, drop cleanup Testing against a real UniFi OS Server showed the server enforces name uniqueness independently of fingerprint uniqueness, and that activation is exclusive server-wide regardless of name/domain. A unique name per upload avoids the name-collision path entirely (previously only handled as a retry-of-identical-content edge case), and removes the need for the post-hoc cleanup loop, which risked deleting the wrong entry. Co-Authored-By: Claude Sonnet 5 * Shorten generated certificate name to Unix epoch seconds Real-hardware testing showed the UniFi OS Server certificate list's name column is fixed-width and doesn't wrap, so a full human-readable timestamp overlaps the Expires column and makes both unreadable. Epoch seconds are still short enough to fit while remaining unique. Co-Authored-By: Claude Sonnet 5 * Add scoped cleanup of old certificate entries, use _time helper Per review: dropping cleanup entirely went further than the original bug required, and left old entries (each holding a private key) accumulating indefinitely. Since every upload now gets a name unique to its domain and run, cleanup can safely target only entries whose name starts with that domain -- entries this hook itself created -- excluding the one just activated. Also swaps date +%s for the core _time helper, and rewrote the design comments to make them clearer and match the current behavior instead of the pre-redesign one. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- deploy/unifios.sh | 307 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 deploy/unifios.sh diff --git a/deploy/unifios.sh b/deploy/unifios.sh new file mode 100644 index 00000000..82b65c69 --- /dev/null +++ b/deploy/unifios.sh @@ -0,0 +1,307 @@ +#!/usr/bin/env sh +# Deploy hook for UniFi OS Server (self-hosted). +# +# Supports: +# - UniFi OS Server on macOS +# - UniFi OS Server on Linux +# - UniFi OS Server on Windows should also work (runs under WSL2), but +# has not been tested. +# +# Tested on: Ubuntu 26.04 (remote) and macOS 26.6 (local). +# +# This is a different product from the Cloud Key / UDM hardware and +# self-hosted Unifi Controller covered by the `unifi` deploy hook above +# (that hook already covers Cloud Key running UnifiOS v2.0.0+/Gen2/2+) -- +# this hook targets the separately-installed, self-hosted "UniFi OS Server" +# application instead, which stores certificates in its own Postgres +# database via a REST API rather than a Java keystore, so the `unifi` +# hook's approach does not apply here. +# +# UniFi OS Server exposes a REST API on its management port (default +# 11443) that its own web UI uses for certificate management: +# POST /api/auth/login - session login (cookie + JWT) +# GET /api/userCertificates - list uploaded certificates +# POST /api/userCertificates - upload a new certificate +# DELETE /api/userCertificates/{id} - remove a certificate +# PUT /api/userCertificates/{id}/status - activate/deactivate a certificate +# +# This was reverse-engineered from the browser's Network tab while using the +# real GUI upload/activate/delete flow -- it is undocumented but is the same +# code path the UI uses, so it's far more robust than editing settings.yaml, +# http/local-certs.conf, or the underlying Postgres user_certificates table +# directly (all of which are also touched by this API, but only as a result +# of the app's own internal logic, which handles cert parsing, active-cert +# bookkeeping, and nginx config regeneration correctly on its own). +# +# Auth: POST /api/auth/login returns a `TOKEN` cookie containing a JWT whose +# payload has a `csrfToken` claim. That value must be echoed back as the +# `x-csrf-token` header on every subsequent state-changing request (a classic +# double-submit CSRF pattern). No other cookies were found to be necessary. +# +# Uses core acme.sh helpers throughout (_post/_get, _json_encode, +# _durl_replace_base64, _dbase64, _egrep_o) rather than raw curl -k or +# python3, so the wget fallback, --debug tracing, and CA_BUNDLE are all +# honored the same as every other hook. The management API's cert is +# self-signed (it's a management-only port, not meant for public exposure), +# so this hook sets HTTPS_INSECURE=1 itself, scoped to its own subshell (see +# acme.sh's per-hook sourcing in _deploy) -- it does not weaken TLS +# verification for the rest of the acme.sh run, e.g. the connection to the +# ACME CA. +# +# Design: This hook does not save a certificate ID between renewals. Each +# upload gets a name unique to that run: the domain name plus a timestamp. +# This name never collides with an entry from a previous deploy. This is +# true even if that entry is still active. The hook uploads and activates +# the new certificate before it removes any old entries. If a failure +# occurs during this process, the server still has a valid, active +# certificate. The hook removes old entries only after activation is +# complete. It removes only entries whose name starts with the domain name, +# because this is the hook's own naming convention. As a result, this step +# can only affect entries that this hook created for this domain. It can +# never affect a certificate that a user uploaded manually, and it can +# never affect a self-signed certificate. +# +# Settings: +# DEPLOY_UNIFIOS_HOST - base URL of the management API +# (default: "https://localhost:11443") +# DEPLOY_UNIFIOS_USERNAME - UniFi OS Server admin username (required) +# DEPLOY_UNIFIOS_PASSWORD - UniFi OS Server admin password (required) +# +# Example: +# export DEPLOY_UNIFIOS_USERNAME="acmeuser" +# export DEPLOY_UNIFIOS_PASSWORD="xxxxx" +# acme.sh --deploy -d example.com --deploy-hook unifios +# +# Please report bugs to https://github.com/acmesh-official/acme.sh/issues/7182 + +_uos_response_code() { + # tr strips the trailing newline along with form feeds; re-terminate + # before the second _egrep_o, whose sed fallback (used wherever egrep -o + # is unavailable) drops an unterminated final line on some platforms. + _uos_code="$(_egrep_o <"$HTTP_HEADER" "^HTTP[^ ]* .*$" | cut -d " " -f 2-100 | tr -d "\f\n")" + printf '%s\n' "$_uos_code" | _egrep_o "^[0-9][0-9]*" +} + +_uos_response_cookie() { + # $1 = cookie name + grep <"$HTTP_HEADER" -i "^Set-Cookie: *$1=" | _tail_n 1 | _egrep_o "$1=[^;]*" | _head_n 1 +} + +unifios_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" + + # Scoped to this hook's own subshell -- does not affect the rest of the + # acme.sh run (e.g. the connection to the ACME CA). + export HTTPS_INSECURE=1 + + _getdeployconf DEPLOY_UNIFIOS_HOST + DEPLOY_UNIFIOS_HOST="${DEPLOY_UNIFIOS_HOST:-https://localhost:11443}" + _savedeployconf DEPLOY_UNIFIOS_HOST "$DEPLOY_UNIFIOS_HOST" + _debug DEPLOY_UNIFIOS_HOST "$DEPLOY_UNIFIOS_HOST" + + _getdeployconf DEPLOY_UNIFIOS_USERNAME + _getdeployconf DEPLOY_UNIFIOS_PASSWORD + + if [ -z "$DEPLOY_UNIFIOS_USERNAME" ] || [ -z "$DEPLOY_UNIFIOS_PASSWORD" ]; then + _err "DEPLOY_UNIFIOS_USERNAME and DEPLOY_UNIFIOS_PASSWORD must be set." + return 1 + fi + _debug DEPLOY_UNIFIOS_USERNAME "$DEPLOY_UNIFIOS_USERNAME" + _secure_debug DEPLOY_UNIFIOS_PASSWORD "$DEPLOY_UNIFIOS_PASSWORD" + + _info "Logging in to UniFi OS Server API at $DEPLOY_UNIFIOS_HOST..." + + # _json_encode always appends a trailing "\n" escape, even to input with + # no trailing newline (it normalizes via `echo`, unconditionally adding + # one). That's harmless for the key/cert file content below, which + # legitimately ends in a real newline anyway, but wrong for these plain + # strings -- strip the spurious escape it leaves behind. + _uos_user_json="$(printf '%s' "$DEPLOY_UNIFIOS_USERNAME" | _json_encode)" + _uos_user_json="${_uos_user_json%\\n}" + _uos_pass_json="$(printf '%s' "$DEPLOY_UNIFIOS_PASSWORD" | _json_encode)" + _uos_pass_json="${_uos_pass_json%\\n}" + _login_body="{\"username\":\"$_uos_user_json\",\"password\":\"$_uos_pass_json\",\"token\":\"\",\"rememberMe\":false}" + + _login_json="$(_post "$_login_body" "$DEPLOY_UNIFIOS_HOST/api/auth/login" "" "POST" "application/json")" + _login_code="$(_uos_response_code)" + + if [ "$_login_code" != "200" ]; then + _err "Login failed (HTTP $_login_code)." + _err "Response: $_login_json" + return 1 + fi + + # Credentials are proven correct now -- save them, rather than only at the + # very end, so a later step failing doesn't discard a working login. + # base64-encoded: _save_conf wraps values in single quotes with no + # escaping, so a literal "'" in the password would otherwise corrupt the + # domain conf (see deploy/synology_dsm.sh for the same pattern). + _savedeployconf DEPLOY_UNIFIOS_USERNAME "$DEPLOY_UNIFIOS_USERNAME" "base64" + _savedeployconf DEPLOY_UNIFIOS_PASSWORD "$DEPLOY_UNIFIOS_PASSWORD" "base64" + + _uos_token="$(_uos_response_cookie TOKEN)" + if [ -z "$_uos_token" ]; then + _err "Login succeeded but no TOKEN cookie was returned." + return 1 + fi + + _H1="Cookie: $_uos_token" + export _H1 + + _uos_jwt_payload="$(echo "$_uos_token" | cut -d '=' -f 2- | cut -d '.' -f 2)" + _uos_csrf="$(_durl_replace_base64 "$_uos_jwt_payload" | _dbase64 | _egrep_o '"csrfToken":"[^"]*"' | cut -d '"' -f 4)" + if [ -z "$_uos_csrf" ]; then + _err "Could not extract csrfToken from session token." + return 1 + fi + + _H2="x-csrf-token: $_uos_csrf" + export _H2 + + _info "Uploading new certificate..." + # "name" is a purely cosmetic label -- the server never validates it + # against the certificate's actual CN/SAN, and accepts arbitrary text + # including spaces (confirmed: a cert for example.com served correctly + # after being uploaded under the unrelated name "totally unrelated label"). + # The only constraint that matters here is uniqueness: the server rejects + # a second entry with a name it already has, so a bare domain name would + # collide with the previous deploy's entry on every renewal after the + # first. A full human-readable timestamp would make that obvious in the + # UI, but the certificate list's name column is fixed-width and doesn't + # wrap (confirmed against the real UI: a long name overlaps the Expires + # column and makes both unreadable), so keep the suffix short instead -- + # Unix epoch seconds are still unique enough for this purpose. + _uos_name="$_cdomain $(_time)" + _uos_key_json="$(_json_encode <"$_ckey")" + _uos_cert_json="$(_json_encode <"$_cfullchain")" + _create_body="{\"name\":\"$_uos_name\",\"key\":\"$_uos_key_json\",\"cert\":\"$_uos_cert_json\"}" + + _create_json="$(_post "$_create_body" "$DEPLOY_UNIFIOS_HOST/api/userCertificates" "" "POST" "application/json")" + _create_code="$(_uos_response_code)" + + if [ "$_create_code" = "201" ]; then + _new_id="$(echo "$_create_json" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + if [ -z "$_new_id" ]; then + _err "Could not determine new certificate ID from upload response." + return 1 + fi + elif [ "$_create_code" = "400" ] && echo "$_create_json" | grep -q "USER_CERTIFICATE_DUPLICATE"; then + # HTTP 400 alone just means "bad request" -- it's the USER_CERTIFICATE_DUPLICATE + # code in the response body, checked above, that actually confirms this. + # The name above is unique to this run, so a duplicate here can only be + # the server's other uniqueness constraint: this exact certificate (by + # fingerprint) already exists as some other entry -- most likely a retry + # after a prior run already uploaded it (a real renewal always produces a + # new fingerprint, so this shouldn't happen in normal cron use). The + # response body doesn't include the existing entry's id, so look it up + # by fingerprint instead. + # The API's own fingerprint field is SHA-1 (20 bytes), not SHA-256 -- + # confirmed against a real response, e.g. + # "fingerprint":"FC:02:50:9C:3B:3F:B7:79:9D:CA:4D:7C:AC:92:E7:D5:EA:F1:3A:29" + # (20 colon-separated groups). _fingerprint (core helper) strips the + # colons that field has, so re-insert them rather than stripping the + # JSON's own colons, which would also remove the ones separating every + # key from its value. + _uos_fingerprint="$(_fingerprint "$_cfullchain" sha1)" + if [ -z "$_uos_fingerprint" ]; then + _err "Could not compute the certificate's fingerprint." + return 1 + fi + _uos_fingerprint="$(echo "$_uos_fingerprint" | sed 's/\(..\)/\1:/g; s/:$//')" + + _list_json="$(_get "$DEPLOY_UNIFIOS_HOST/api/userCertificates")" + _list_code="$(_uos_response_code)" + if [ "$_list_code" != "200" ]; then + _err "Failed to list existing certificates (HTTP $_list_code)." + _err "Response: $_list_json" + return 1 + fi + # _normalizeJson collapses the response to one predictable line (no stray + # whitespace around colons, no embedded CR/LF the server might emit) but + # also strips the trailing newline entirely -- re-terminate before the + # split below, since some sed implementations drop an unterminated final + # line rather than processing it. + _list_json="$(echo "$_list_json" | _normalizeJson)" + # A literal embedded newline (not the two-character "\n", which GNU sed + # treats as a newline in the replacement but POSIX doesn't define and BSD + # sed emits literally) splits it one JSON object per line so grep can + # match a single certificate entry at a time. + _list_json="$( + printf '%s\n' "$_list_json" | sed 's/},{/},\ +{/g' + )" + _new_id="$(echo "$_list_json" | grep -F "\"fingerprint\":\"$_uos_fingerprint\"" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + if [ -z "$_new_id" ]; then + _err "Certificate upload rejected as a duplicate (server reported USER_CERTIFICATE_DUPLICATE), but no existing entry matching this fingerprint was found." + _err "Response: $_create_json" + return 1 + fi + # Reusing the existing entry rather than deleting it and re-uploading + # under today's name+timestamp: the served content is identical either + # way, so replacing it would only cost an extra delete+create round trip + # for no functional benefit. The tradeoff is cosmetic -- this entry keeps + # whatever name it was given whenever it was originally uploaded, so it + # won't reflect today's date in the UI. + _info "Certificate already present as entry $_new_id; reusing it." + else + _err "Certificate upload failed (HTTP $_create_code)." + _err "Response: $_create_json" + return 1 + fi + + _info "Activating certificate $_new_id..." + _activate_json="$(_post '{"active":true}' "$DEPLOY_UNIFIOS_HOST/api/userCertificates/$_new_id/status" "" "PUT" "application/json")" + _activate_code="$(_uos_response_code)" + + if [ "$_activate_code" != "200" ]; then + _err "Failed to activate new certificate (HTTP $_activate_code)." + _err "Response: $_activate_json" + return 1 + fi + + # UniFi OS Server activation is exclusive server-wide. Tests against the + # real API confirm this: activation of one entry deactivates whichever + # other entry was active before, no matter its name or domain. As a + # result, the server serves the certificate that this hook just activated. + # This certificate is already live. If the removal of old entries below + # fails, the hook logs the failure. The deploy does not fail because of + # this. + _info "Checking for old certificate entries to remove..." + _list_json="$(_get "$DEPLOY_UNIFIOS_HOST/api/userCertificates")" + _list_code="$(_uos_response_code)" + if [ "$_list_code" != "200" ]; then + _err "Failed to list certificates for cleanup (HTTP $_list_code) -- leaving old entries in place." + else + _list_json="$(echo "$_list_json" | _normalizeJson)" + _list_json="$( + printf '%s\n' "$_list_json" | sed 's/},{/},\ +{/g' + )" + # The pattern below matches the domain name followed by a space. If the + # space is missing, the pattern can also match a different domain that + # starts with the same text as this domain. + _old_ids="$(echo "$_list_json" | grep -F "\"name\":\"$_cdomain " | _egrep_o '"id":"[^"]*"' | cut -d '"' -f 4 | grep -v "^$_new_id$")" + for _old_id in $_old_ids; do + _info "Removing old certificate entry $_old_id..." + _del_json="$(_post "" "$DEPLOY_UNIFIOS_HOST/api/userCertificates/$_old_id" "" "DELETE")" + _del_code="$(_uos_response_code)" + if [ "$_del_code" != "204" ] && [ "$_del_code" != "200" ]; then + _err "Failed to delete old certificate $_old_id (HTTP $_del_code) -- leaving it in place." + _err "Response: $_del_json" + fi + done + fi + + _info "UniFi OS Server certificate deployed and activated successfully." + return 0 +}