* Merge pull request #7220 from moezx/dev

Add AK & SK based Huawei Cloud DNS API

* fix: dynv6 record parsing (#7197)

* unifios: extract JSON-split helper, document RSA/ECC name collision (#7200)

* Extract _uos_split_json helper, document RSA/ECC name-prefix collision

Per neilpang's non-blocking review notes on #7184: the _normalizeJson +
split-into-lines block was duplicated at both call sites, now shared via
_uos_split_json(). Also documents (without changing behavior, since it's
harmless today) that an RSA and ECC deploy of the same domain share the
generated name's prefix, each removing the other's entry on cleanup --
citing haproxy.sh/lighttpd.sh's existing .rsa/.ecdsa suffix pattern as
the fix if this ever needs addressing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Replace grep -F with a portable matcher, fix RSA/ECC name collision

grep -F isn't on Solaris, and dropping it naively breaks matching:
wildcard domains and dots collide as regex. _uos_grep_literal replaces
both call sites with a case-based literal match instead.

_uos_name now includes the key type, so RSA and ECC deploys of the
same domain no longer share a cleanup scope.

Per neilpang's review on #7200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix echo's \n handling in _uos_grep_literal, drop unneeded Le_Keylength guard

echo does not behave consistently across different environments. dash
interprets literal \n in a line, splitting it.  printf '%s\n' does not and matches
_uos_split_json's existing pattern. printf behaves more consistently across
environments and is generally preferred over echo.

Le_Keylength guard was a no-op and didn't help under set -u either;
_isEccKey already handles empty. Kept the shellcheck warning suppressed
inline instead of assigning to a core Le_* var.

Per neilpang's review on #7200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* add trigger

* minor

---------

Co-authored-by: Mashiro <adadam@qq.com>
Co-authored-by: MBWhitestone <25477219+MBWhitestone@users.noreply.github.com>
Co-authored-by: Pablo <Pablo1@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
neil
2026-09-04 15:30:06 +08:00
committed by GitHub
6 changed files with 329 additions and 39 deletions

View File

@@ -10,9 +10,19 @@ on:
- '**.sh'
- "Dockerfile"
- '.github/workflows/dockerhub.yml'
# Rebuild the latest release tag weekly so a pinned version tag picks up
# Alpine package security updates (see issue 7209).
schedule:
- cron: '17 3 * * 1'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to rebuild (empty = latest release)'
required: false
default: ''
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
env:
@@ -45,9 +55,27 @@ jobs:
contents: read
packages: write
steps:
- name: resolve the release tag to rebuild
id: rebuild
if: github.event_name != 'push'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: |
tag="$INPUT_TAG"
if [ -z "$tag" ]; then
tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)"
fi
if [ -z "$tag" ]; then
echo "::error::cannot resolve the release tag to rebuild"
exit 1
fi
echo "rebuilding release tag ${tag}"
echo "tag=${tag}" >>"$GITHUB_OUTPUT"
- name: checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ steps.rebuild.outputs.tag }}
persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -65,12 +93,15 @@ jobs:
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: build and push the image
env:
REBUILD_TAG: ${{ steps.rebuild.outputs.tag }}
run: |
if [[ $GITHUB_REF == refs/tags/* ]]; then
if [ -n "$REBUILD_TAG" ]; then
# scheduled/manual rebuild of an existing release tag
DOCKER_IMAGE_TAG=${REBUILD_TAG}
elif [[ $GITHUB_REF == refs/tags/* ]]; then
DOCKER_IMAGE_TAG=${GITHUB_REF#refs/tags/}
fi
if [[ $GITHUB_REF == refs/heads/* ]]; then
elif [[ $GITHUB_REF == refs/heads/* ]]; then
DOCKER_IMAGE_TAG=${GITHUB_REF#refs/heads/}
if [[ $DOCKER_IMAGE_TAG == master ]]; then
@@ -86,6 +117,13 @@ jobs:
DOCKER_LABELS+=(--label "${label}")
done <<<"${DOCKER_METADATA_OUTPUT_LABELS}"
if [ -n "$REBUILD_TAG" ]; then
# the metadata action derived version/revision from the default
# branch; a later --label wins, so point them at the rebuilt tag
DOCKER_LABELS+=(--label "org.opencontainers.image.version=${REBUILD_TAG}")
DOCKER_LABELS+=(--label "org.opencontainers.image.revision=$(git rev-parse HEAD)")
fi
docker buildx build \
--tag ${DOCKER_IMAGE}:${DOCKER_IMAGE_TAG} \
"${DOCKER_LABELS[@]}" \

View File

@@ -173,10 +173,7 @@ haproxy_deploy() {
# Set the suffix depending if we are creating a bundle or not
if [ "${Le_Deploy_haproxy_bundle}" = "yes" ]; then
_info "Bundle creation requested"
# Initialise $Le_Keylength if its not already set
if [ -z "${Le_Keylength}" ]; then
Le_Keylength=""
fi
# shellcheck disable=SC2154 # Le_Keylength is set by acme.sh core, not this hook
if _isEccKey "${Le_Keylength}"; then
_info "ECC key type detected"
_suffix=".ecdsa"

View File

@@ -121,10 +121,7 @@ lighttpd_deploy() {
# Set the suffix depending if we are creating a bundle or not
if [ "${Le_Deploy_lighttpd_bundle}" = "yes" ]; then
_info "Bundle creation requested"
# Initialise $Le_Keylength if its not already set
if [ -z "${Le_Keylength}" ]; then
Le_Keylength=""
fi
# shellcheck disable=SC2154 # Le_Keylength is set by acme.sh core, not this hook
if _isEccKey "${Le_Keylength}"; then
_info "ECC key type detected"
_suffix=".ecdsa"

View File

@@ -95,6 +95,36 @@ _uos_response_cookie() {
grep <"$HTTP_HEADER" -i "^Set-Cookie: *$1=" | _tail_n 1 | _egrep_o "$1=[^;]*" | _head_n 1
}
_uos_split_json() {
# $1 = raw JSON list response
#
# _normalizeJson collapses the response to one line. This removes extra
# space around colons. It also removes any CR or LF characters that the
# server can add. However, _normalizeJson also removes the newline
# character at the end of the line. If the line has no ending newline
# character, some sed programs drop the last line of input. This code
# adds the newline back before the split below, to prevent that problem.
_uos_normalized="$(echo "$1" | _normalizeJson)"
# A literal newline character splits the JSON into one object per line.
# Grep can then match a single certificate entry at a time. This is not
# the two-character "\n" sequence: GNU sed reads "\n" in the replacement
# text as a newline character. POSIX does not define this behavior, and
# BSD sed prints "\n" as two literal characters, not as a newline.
printf '%s\n' "$_uos_normalized" | sed 's/},{/},\
{/g'
}
_uos_grep_literal() {
# $1 = literal text to find, matched without a regex -- portable to
# grep implementations with no -F flag (e.g. Solaris), and avoids "*"
# or "." in a domain name being read as a regex metacharacter.
while IFS= read -r _uos_line || [ -n "$_uos_line" ]; do
case "$_uos_line" in
*"$1"*) printf '%s\n' "$_uos_line" ;;
esac
done
}
unifios_deploy() {
_cdomain="$1"
_ckey="$2"
@@ -189,7 +219,20 @@ unifios_deploy() {
# 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)"
#
# This name includes the key type (rsa or ecdsa), to keep an RSA
# deploy and an ECC deploy of the same domain from sharing this
# prefix. Without the key type, the cleanup step for each deploy
# removes the entry that the other deploy creates. `deploy/haproxy.sh`
# and `deploy/lighttpd.sh` use the same `_isEccKey` check, for the same
# reason.
# shellcheck disable=SC2154 # Le_Keylength is set by acme.sh core, not this hook
if _isEccKey "${Le_Keylength}"; then
_uos_keytype="ecdsa"
else
_uos_keytype="rsa"
fi
_uos_name="$_cdomain $_uos_keytype $(_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\"}"
@@ -234,21 +277,8 @@ unifios_deploy() {
_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)"
_list_json="$(_uos_split_json "$_list_json")"
_new_id="$(echo "$_list_json" | _uos_grep_literal "\"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"
@@ -290,15 +320,11 @@ unifios_deploy() {
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$")"
_list_json="$(_uos_split_json "$_list_json")"
# The pattern below matches the domain name and key type, 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" | _uos_grep_literal "\"name\":\"$_cdomain $_uos_keytype " | _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")"

View File

@@ -150,6 +150,9 @@ _dns_dynv6_add_http() {
fi
_get_zone_name "$_zone_id"
record=${fulldomain%%."$_zone_name"}
if [ "$fulldomain" = "$_zone_name" ]; then
record=""
fi
_set_record TXT "$record" "$txtvalue"
if _contains "$response" "$txtvalue"; then
_info "Successfully added record"
@@ -168,6 +171,9 @@ _dns_dynv6_rm_http() {
fi
_get_zone_name "$_zone_id"
record=${fulldomain%%."$_zone_name"}
if [ "$fulldomain" = "$_zone_name" ]; then
record=""
fi
_get_record_id "$_zone_id" "$record" "$txtvalue"
_del_record "$_zone_id" "$_record_id"
if [ -z "$response" ]; then

226
dnsapi/dns_hw.sh Normal file
View File

@@ -0,0 +1,226 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_hw_info='Huawei Cloud DNS
Site: HuaweiCloud.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_hw
Options:
HW_AK Access Key
HW_SK Secret Access Key
HW_Region Region. E.g. "cn-north-4". Optional, defaults to "cn-north-4".
Issues: github.com/acmesh-official/acme.sh/issues/7221
Author: mashirozx
'
dns_hw_add() {
fulldomain=$1
txtvalue=$2
if ! _hw_init; then
return 1
fi
if ! _hw_get_zoneid "$fulldomain"; then
return 1
fi
if ! _hw_get_recordset "$fulldomain" "$_hw_zoneid"; then
return 1
fi
# Huawei Cloud stores each TXT value with its required inner quotes.
_hw_txt_record="\"\\\"${txtvalue}\\\"\""
case "$_hw_records" in
*"$txtvalue"*)
_debug "TXT record already exists"
;;
*)
if [ -z "$_hw_recordid" ]; then
_hw_body="{\"name\":\"${fulldomain}.\",\"type\":\"TXT\",\"ttl\":300,\"records\":[${_hw_txt_record}]}"
# Create a TXT record set: https://support.huaweicloud.com/api-dns/dns_api_64001.html
_hw_rest "POST" "/v2/zones/${_hw_zoneid}/recordsets" "" "$_hw_body" || return 1
else
_hw_body="{\"name\":\"${fulldomain}.\",\"type\":\"TXT\",\"ttl\":${_hw_recordttl},\"records\":[${_hw_records},${_hw_txt_record}]}"
# Update the existing TXT record set: https://support.huaweicloud.com/api-dns/UpdateRecordSets.html
_hw_rest "PUT" "/v2/zones/${_hw_zoneid}/recordsets/${_hw_recordid}" "" "$_hw_body" || return 1
fi
;;
esac
_saveaccountconf_mutable HW_AK "$HW_AK"
_saveaccountconf_mutable HW_SK "$HW_SK"
if [ -n "$HW_Region" ]; then
_saveaccountconf_mutable HW_Region "$HW_Region"
fi
}
dns_hw_rm() {
fulldomain=$1
txtvalue=$2
if ! _hw_init; then
return 1
fi
if ! _hw_get_zoneid "$fulldomain" || ! _hw_get_recordset "$fulldomain" "$_hw_zoneid"; then
return 1
fi
if [ -z "$_hw_recordid" ]; then
_debug "TXT record not found"
return 0
fi
# Keep unrelated TXT values that share this record set.
_hw_txt_record="\"\\\"${txtvalue}\\\"\""
case "$_hw_records" in
*"$txtvalue"*) ;;
*)
_debug "TXT record value not found"
return 0
;;
esac
_hw_sed_txt_record=$(echo "$_hw_txt_record" | sed 's/\\/\\\\/g')
_hw_new_records=$(echo "$_hw_records" | sed "s/${_hw_sed_txt_record},//; s/,${_hw_sed_txt_record}//; s/${_hw_sed_txt_record}//")
if [ -z "$_hw_new_records" ]; then
# Delete an empty TXT record set: https://support.huaweicloud.com/api-dns/dns_api_64005.html
_hw_rest "DELETE" "/v2/zones/${_hw_zoneid}/recordsets/${_hw_recordid}" "" "" || return 1
else
_hw_body="{\"name\":\"${fulldomain}.\",\"type\":\"TXT\",\"ttl\":${_hw_recordttl},\"records\":[${_hw_new_records}]}"
# Update the record set after removing this challenge value: https://support.huaweicloud.com/api-dns/UpdateRecordSets.html
_hw_rest "PUT" "/v2/zones/${_hw_zoneid}/recordsets/${_hw_recordid}" "" "$_hw_body" || return 1
fi
}
_hw_init() {
# Credentials from the environment override the persisted account settings.
HW_AK="${HW_AK:-$(_readaccountconf_mutable HW_AK)}"
HW_SK="${HW_SK:-$(_readaccountconf_mutable HW_SK)}"
HW_Region="${HW_Region:-$(_readaccountconf_mutable HW_Region)}"
if [ -z "$HW_AK" ] || [ -z "$HW_SK" ]; then
_err "You don't specify Huawei Cloud Access Key and Secret Access Key yet."
return 1
fi
_hw_region="${HW_Region:-cn-north-4}"
_hw_api="https://dns.${_hw_region}.myhuaweicloud.com"
_hw_host="dns.${_hw_region}.myhuaweicloud.com"
}
_hw_get_zoneid() {
_hw_domain=$1
_hw_index=1
# Try successively shorter suffixes so delegated zones are supported.
while true; do
_hw_zone_name=$(echo "$_hw_domain" | cut -d . -f "$_hw_index"-100)
if [ -z "$_hw_zone_name" ]; then
_err "Could not find Huawei Cloud DNS zone for $_hw_domain"
return 1
fi
_hw_query="name=$(printf "%s" "$_hw_zone_name" | _url_encode upper-hex)&search_mode=equal"
# List public zones to find the authoritative zone: https://support.huaweicloud.com/api-dns/dns_api_62003.html
if ! _hw_rest "GET" "/v2/zones" "$_hw_query" ""; then
return 1
fi
_hw_zoneid=$(echo "$_hw_response" | _egrep_o '"id"[ ]*:[ ]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
_hw_returned_name=$(echo "$_hw_response" | _egrep_o '"name"[ ]*:[ ]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
if [ -n "$_hw_zoneid" ] && [ "$_hw_returned_name" = "${_hw_zone_name}." ]; then
return 0
fi
_hw_index=$(_math "$_hw_index" + 1)
done
}
_hw_get_recordset() {
_hw_domain=$1
_hw_zone=$2
_hw_recordid=""
_hw_records=""
_hw_recordttl=""
_hw_query="name=$(printf "%s" "$_hw_domain" | _url_encode upper-hex)&search_mode=equal&type=TXT"
# List TXT record sets to locate the existing challenge record: https://support.huaweicloud.com/api-dns/dns_api_64004.html
if ! _hw_rest "GET" "/v2/zones/${_hw_zone}/recordsets" "$_hw_query" ""; then
return 1
fi
_hw_recordid=$(echo "$_hw_response" | _egrep_o '"id"[ ]*:[ ]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
_hw_returned_name=$(echo "$_hw_response" | _egrep_o '"name"[ ]*:[ ]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
if [ -z "$_hw_recordid" ]; then
return 0
fi
_hw_expected_name="$(_lower_case "${_hw_domain}.")"
if [ "$(_lower_case "$_hw_returned_name")" != "$_hw_expected_name" ]; then
_err "Huawei Cloud DNS returned an unexpected record set for $_hw_domain"
return 1
fi
# A DNS record set may contain multiple TXT values for concurrent challenges.
_hw_records=$(echo "$_hw_response" | sed 's/.*"records"[ ]*:[ ]*\[//; s/\].*//' | tr -d '\r\n')
_hw_recordttl=$(echo "$_hw_response" | _egrep_o '"ttl"[ ]*:[ ]*[0-9]*' | _head_n 1 | cut -d : -f 2 | tr -d ' ')
if [ -z "$_hw_recordttl" ]; then
_err "Huawei Cloud DNS record set did not include a TTL"
return 1
fi
}
_hw_sha256() {
printf "%s" "$1" | _digest sha256 hex
}
_hw_hmac() {
_hw_key_hex=$(printf "%s" "$1" | _hex_dump | tr -d ' ')
printf "%s" "$2" | _hmac sha256 "$_hw_key_hex" hex
}
_hw_rest() {
_hw_method=$1
_hw_uri=$2
_hw_query=$3
_hw_payload=$4
_H1=""
_H2=""
_H3=""
_H4=""
_H5=""
_hw_date=$(_utc_date | tr -d ' :-')
_hw_short_date=${_hw_date%??????}
_hw_date="${_hw_short_date}T${_hw_date#????????}Z"
# Huawei's API gateway signs a trailing slash even when the published URI has none.
_hw_canonical_uri="${_hw_uri%/}/"
# SDK-HMAC-SHA256 signs the exact canonical request sent to Huawei Cloud.
_hw_payload_hash=$(_hw_sha256 "$_hw_payload")
_hw_headers="content-type:application/json
host:${_hw_host}
x-sdk-date:${_hw_date}
"
_hw_signed_headers="content-type;host;x-sdk-date"
_hw_canonical_request="${_hw_method}
${_hw_canonical_uri}
${_hw_query}
${_hw_headers}
${_hw_signed_headers}
${_hw_payload_hash}"
_hw_string_to_sign="SDK-HMAC-SHA256
${_hw_date}
$(_hw_sha256 "$_hw_canonical_request")"
_hw_signature=$(_hw_hmac "$HW_SK" "$_hw_string_to_sign")
_H1="Content-Type: application/json"
_H2="Host: ${_hw_host}"
_H3="X-Sdk-Date: ${_hw_date}"
_H4="Authorization: SDK-HMAC-SHA256 Access=${HW_AK}, SignedHeaders=${_hw_signed_headers}, Signature=${_hw_signature}"
_hw_url="${_hw_api}${_hw_uri}"
if [ -n "$_hw_query" ]; then
_hw_url="${_hw_url}?${_hw_query}"
fi
# _post sends the canonical request with each signed header exactly once.
if [ -z "$HTTP_HEADER" ]; then
_err "HTTP header file is not initialized"
return 1
fi
: >"$HTTP_HEADER" || return 1
if ! _hw_response=$(_post "$_hw_payload" "$_hw_url" "" "$_hw_method"); then
_err "Huawei Cloud DNS API request failed"
return 1
fi
_hw_code=$(grep '^HTTP' "$HTTP_HEADER" | _tail_n 1 | cut -d ' ' -f 2 | tr -d '\r\n')
if ! _startswith "$_hw_code" "2"; then
_err "Huawei Cloud DNS API error: HTTP $_hw_code"
_debug2 response "$_hw_response"
return 1
fi
}