diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml index 7dc42290..266bbf8a 100644 --- a/.github/workflows/dockerhub.yml +++ b/.github/workflows/dockerhub.yml @@ -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[@]}" \ diff --git a/deploy/haproxy.sh b/deploy/haproxy.sh index 9736e6ff..a3973f14 100644 --- a/deploy/haproxy.sh +++ b/deploy/haproxy.sh @@ -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" diff --git a/deploy/lighttpd.sh b/deploy/lighttpd.sh index 71f64b96..0ef4330b 100644 --- a/deploy/lighttpd.sh +++ b/deploy/lighttpd.sh @@ -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" diff --git a/deploy/unifios.sh b/deploy/unifios.sh index 05298c9f..aa3ce23a 100644 --- a/deploy/unifios.sh +++ b/deploy/unifios.sh @@ -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")" diff --git a/dnsapi/dns_dynv6.sh b/dnsapi/dns_dynv6.sh index 3e7ce8d6..07dbac9c 100644 --- a/dnsapi/dns_dynv6.sh +++ b/dnsapi/dns_dynv6.sh @@ -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 diff --git a/dnsapi/dns_hw.sh b/dnsapi/dns_hw.sh new file mode 100644 index 00000000..965bc940 --- /dev/null +++ b/dnsapi/dns_hw.sh @@ -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 +}