From 9cdccb8f0ce3b9e8d8b332e3a0b84b2d49d14581 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 11 Feb 2026 18:22:31 +0300 Subject: [PATCH 1/9] dns_yc: fix key handling and rm behavior Do not delete user-provided YC_SA_Key_File_Path. Prepare key file in dns_yc_rm same as dns_yc_add. Use mktemp for PEM_b64 key. Cleanup via trap. --- dnsapi/dns_yc.sh | 175 +++++++++++++++++++++++++++-------------------- 1 file changed, 100 insertions(+), 75 deletions(-) diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index 36c49ce4..c167e1a0 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -1,5 +1,6 @@ -#!/usr/bin/env sh +#!/usr/bin/env bash # shellcheck disable=SC2034 + dns_yc_info='Yandex Cloud DNS Site: Cloud.Yandex.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_yc @@ -22,59 +23,34 @@ dns_yc_add() { fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name txtvalue=$2 - 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:-$(_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" - _savedomainconf YC_SA_Key_File_PEM_b64 "$YC_SA_Key_File_PEM_b64" - else - YC_SA_Key_File="$YC_SA_Key_File_Path" - _savedomainconf YC_SA_Key_File_Path "$YC_SA_Key_File_Path" - fi + _yc_prepare_key_file + trap _yc_cleanup_key_file EXIT YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_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 - if [ -f "$YC_SA_Key_File" ]; then - if _isRSA "$YC_SA_Key_File" >/dev/null 2>&1; then - if [ "$YC_Zone_ID" ]; then - _savedomainconf YC_Zone_ID "$YC_Zone_ID" - _savedomainconf YC_SA_ID "$YC_SA_ID" - _savedomainconf YC_SA_Key_ID "$YC_SA_Key_ID" - elif [ "$YC_Folder_ID" ]; then - _savedomainconf YC_Folder_ID "$YC_Folder_ID" - _saveaccountconf_mutable YC_SA_ID "$YC_SA_ID" - _saveaccountconf_mutable YC_SA_Key_ID "$YC_SA_Key_ID" - _clearaccountconf_mutable YC_Zone_ID - _clearaccountconf YC_Zone_ID - else - _err "You didn't specify a Yandex Cloud Zone ID or Folder ID yet." - return 1 - fi - else - _err "YC_SA_Key_File not a RSA file(_isRSA function return false)." - return 1 - fi - else - _err "YC_SA_Key_File not found in path $YC_SA_Key_File." - return 1 - fi - else - _clearaccountconf YC_Zone_ID - _clearaccountconf YC_Folder_ID - _clearaccountconf YC_SA_ID - _clearaccountconf YC_SA_Key_ID - _clearaccountconf YC_SA_Key_File_PEM_b64 - _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." + if ! _yc_validate_creds; then return 1 fi + # Save per-domain or per-account settings + if [ "$YC_Zone_ID" ]; then + _savedomainconf YC_Zone_ID "$YC_Zone_ID" + elif [ "$YC_Folder_ID" ]; then + _savedomainconf YC_Folder_ID "$YC_Folder_ID" + fi + _saveaccountconf_mutable YC_SA_ID "$YC_SA_ID" + _saveaccountconf_mutable YC_SA_Key_ID "$YC_SA_Key_ID" + if [ "${YC_SA_Key_File_PEM_b64:-}" ]; then + _saveaccountconf_mutable YC_SA_Key_File_PEM_b64 "$YC_SA_Key_File_PEM_b64" + _clearaccountconf_mutable YC_SA_Key_File_Path + else + _saveaccountconf_mutable YC_SA_Key_File_Path "$YC_SA_Key_File_Path" + _clearaccountconf_mutable YC_SA_Key_File_PEM_b64 + fi + _debug "First detect the root zone" if ! _get_root "$fulldomain"; then _err "invalid domain" @@ -95,14 +71,11 @@ dns_yc_add() { if _contains "$response" "\"done\": true"; 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 @@ -110,11 +83,18 @@ dns_yc_rm() { fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name txtvalue=$2 + _yc_prepare_key_file + trap _yc_cleanup_key_file EXIT + YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" + if ! _yc_validate_creds; then + return 1 + fi + _debug "First detect the root zone" if ! _get_root "$fulldomain"; then _err "invalid domain" @@ -133,25 +113,24 @@ dns_yc_rm() { return 1 fi + # Note: deletes whole recordset data array, consistent with previous behavior. if _yc_rest POST "zones/$_domain_id:updateRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$exists_txtvalue}]}"; then if _contains "$response" "\"done\": true"; then _info "Delete, OK" return 0 - else - _err "Delete record error." - return 1 fi fi + _err "Delete record error." return 1 } #################### Private functions below ################################## -#_acme-challenge.www.domain.com + #returns # _sub_domain=_acme-challenge.www # _domain=domain.com -# _domain_id=sdjkglgdfewsdfg +# _domain_id= _get_root() { domain=$1 i=1 @@ -161,38 +140,36 @@ _get_root() { if [ "$YC_Zone_ID" ]; then if ! _yc_rest GET "zones/$YC_Zone_ID"; then return 1 - else - if echo "$response" | tr -d " " | _egrep_o "\"id\":\"$YC_Zone_ID\"" >/dev/null; then - _domain=$(echo "$response" | _egrep_o "\"zone\": *\"[^\"]*\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") - if [ "$_domain" ]; then - _cutlength=$((${#domain} - ${#_domain})) - _sub_domain=$(printf "%s" "$domain" | cut -c "1-$_cutlength") - _domain_id=$YC_Zone_ID - return 0 - else - return 1 - fi - else - return 1 + fi + + if echo "$response" | tr -d " " | _egrep_o "\"id\":\"$YC_Zone_ID\"" >/dev/null; then + _domain=$(echo "$response" | _egrep_o "\"zone\": *\"[^\"]*\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") + if [ "$_domain" ]; then + _cutlength=$((${#domain} - ${#_domain})) + _sub_domain=$(printf "%s" "$domain" | cut -c "1-$_cutlength") + _domain_id=$YC_Zone_ID + return 0 fi fi + 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 [ "$YC_Folder_ID" ]; then if ! _yc_rest GET "zones?folderId=$YC_Folder_ID"; then return 1 fi else - echo "You didn't specify a Yandex Cloud Folder ID." + _err "You didn't specify a Yandex Cloud Folder ID." 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 '"') _debug _domain_id "$_domain_id" @@ -206,16 +183,67 @@ _get_root() { p=$i i=$(_math "$i" + 1) done - return 1 +} + +_yc_validate_creds() { + if [ ! "$YC_SA_ID" ] || [ ! "$YC_SA_Key_ID" ] || [ ! "$YC_SA_Key_File" ]; then + _err "You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File." + return 1 + fi + + if [ ! -f "$YC_SA_Key_File" ]; then + _err "YC_SA_Key_File not found in path $YC_SA_Key_File." + return 1 + fi + + if ! _isRSA "$YC_SA_Key_File" >/dev/null 2>&1; then + _err "YC_SA_Key_File not a RSA file(_isRSA function return false)." + return 1 + fi + + if [ ! "$YC_Zone_ID" ] && [ ! "$YC_Folder_ID" ]; then + _err "You didn't specify a Yandex Cloud Zone ID or Folder ID yet." + return 1 + fi + + return 0 +} + +# Prepare YC_SA_Key_File from either PEM_b64 (tmp) or File_Path (persistent) +# Sets: +# YC_SA_Key_File +# YC_SA_Key_File_PEM_b64 / YC_SA_Key_File_Path (from env/accountconf) +# _yc_tmp_key_file (if created) +_yc_prepare_key_file() { + 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:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" + + _yc_tmp_key_file="" + + if [ "$YC_SA_Key_File_PEM_b64" ]; then + _yc_tmp_key_file="$(mktemp "${TMPDIR:-/tmp}/acme-yc-key.XXXXXX")" + chmod 600 "$_yc_tmp_key_file" + echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >"$_yc_tmp_key_file" + YC_SA_Key_File="$_yc_tmp_key_file" + else + YC_SA_Key_File="$YC_SA_Key_File_Path" + fi +} + +# Cleanup only temp key (never touch persistent YC_SA_Key_File_Path) +_yc_cleanup_key_file() { + if [ "${_yc_tmp_key_file:-}" ] && [ -f "${_yc_tmp_key_file}" ]; then + rm -f "${_yc_tmp_key_file}" + fi } _yc_rest() { m=$1 ep="$2" - data="$3" + data="${3-}" _debug "$ep" - if [ ! "$YC_Token" ]; then + if [ ! "${YC_Token:-}" ]; then _debug "Login" _yc_login else @@ -251,12 +279,9 @@ _yc_login() { payload=$(echo "{\"iss\":\"$YC_SA_ID\",\"aud\":\"https://iam.api.cloud.yandex.net/iam/v1/tokens\",\"iat\":$_current_timestamp,\"exp\":$_expire_timestamp}" | _normalizeJson | _base64 | _url_replace) _debug payload "$payload" - #signature=$(printf "%s.%s" "$header" "$payload" | ${ACME_OPENSSL_BIN:-openssl} dgst -sign "$YC_SA_Key_File -sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1" | _base64 | _url_replace ) _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" - _jwt=$(printf "{\"jwt\": \"%s.%s.%s\"}" "$header" "$payload" "$_signature") _debug2 _jwt "$_jwt" From 3fc98b04f206c69ac07280959d471c0ef8ec144c Mon Sep 17 00:00:00 2001 From: root Date: Wed, 11 Feb 2026 18:34:23 +0300 Subject: [PATCH 2/9] dns_yc: keep POSIX sh and use _mktemp for temp key --- 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 c167e1a0..b3ebef0e 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh # shellcheck disable=SC2034 dns_yc_info='Yandex Cloud DNS @@ -24,7 +24,7 @@ dns_yc_add() { txtvalue=$2 _yc_prepare_key_file - trap _yc_cleanup_key_file EXIT + trap _yc_cleanup_key_file 0 YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" @@ -84,7 +84,7 @@ dns_yc_rm() { txtvalue=$2 _yc_prepare_key_file - trap _yc_cleanup_key_file EXIT + trap _yc_cleanup_key_file 0 YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" From 3d3f1a309838e2988ba5f12a0838fff286ebaa28 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Feb 2026 09:48:38 +0300 Subject: [PATCH 3/9] dns_yc: fix TXT record removal to delete only matching value --- dnsapi/dns_yc.sh | 88 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 9 deletions(-) diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index b3ebef0e..f1d646fc 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -105,19 +105,46 @@ dns_yc_rm() { _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 + if ! _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then _err "Error: $response" return 1 fi - # Note: deletes whole recordset data array, consistent with previous behavior. - if _yc_rest POST "zones/$_domain_id:updateRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$exists_txtvalue}]}"; then - if _contains "$response" "\"done\": true"; then - _info "Delete, OK" - return 0 + _existing="$(_yc_extract_txt_data_array)" + _debug existing_data "$_existing" + + # Nothing to delete + if [ -z "$_existing" ]; then + _info "No TXT recordset found, skip." + return 0 + fi + + _newdata="$(_yc_data_array_rm_one "$_existing" "$txtvalue")" + _debug new_data "$_newdata" + + # If value wasn't present, nothing to do + if [ "$_newdata" = "$_existing" ]; then + _info "TXT value not found, skip." + return 0 + fi + + if [ "$_newdata" = "[]" ]; then + # delete whole recordset (with previous data array) + if _yc_rest POST "zones/$_domain_id:updateRecordSets" \ + "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_existing }]}"; then + if _contains "$response" "\"done\": true"; then + _info "Delete, OK" + return 0 + fi + fi + else + # keep remaining values + if _yc_rest POST "zones/$_domain_id:upsertRecordSets" \ + "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_newdata }]}"; then + if _contains "$response" "\"done\": true"; then + _info "Delete, OK" + return 0 + fi fi fi @@ -185,6 +212,49 @@ _get_root() { done } +# Extract TXT recordset "data" array from YC response +# Returns JSON array like ["v1","v2"] or empty string if not found +_yc_extract_txt_data_array() { + echo "$response" | _normalizeJson | _egrep_o "\"data\":\\[[^\\]]*\\]" | _egrep_o "\\[[^\\]]*\\]" +} + +# Remove one txt value from JSON array +# Args: json_array txtvalue +# Prints: new json array (possibly "[]") +_yc_data_array_rm_one() { + _arr="$1" + _val="$2" + + [ -z "$_arr" ] && { printf "[]"; return 0; } + + # remove exact JSON string element occurrences + _new=$(printf "%s" "$_arr" | sed \ + -e "s/\"$_val\",//g" \ + -e "s/,\"$_val\"//g" \ + -e "s/\"$_val\"//g" \ + -e 's/\[,/[/' \ + -e 's/,\]/]/' \ + -e 's/,,/,/g') + + # normalize empty leftovers + _new=$(printf "%s" "$_new" | sed -e 's/\[ *\]/[]/g') + + # if nothing left between brackets -> [] + if _contains "$_new" '[""]'; then + printf "%s" "$_new" + return 0 + fi + + # clean cases like "[" or "]" or "[,]" + if [ "$_new" = "[]" ] || [ "$_new" = "[" ] || [ "$_new" = "]" ] || [ "$_new" = "[,]" ]; then + printf "[]" + return 0 + fi + + printf "%s" "$_new" +} + + _yc_validate_creds() { if [ ! "$YC_SA_ID" ] || [ ! "$YC_SA_Key_ID" ] || [ ! "$YC_SA_Key_File" ]; then _err "You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File." From 9d6fcca23f51ccf6bb773da7d86e017bed360d16 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Feb 2026 11:11:06 +0300 Subject: [PATCH 4/9] dns_yc: fix wildcard handling and TXT record cleanup logic - Properly append TXT values instead of overwriting recordset - Remove only matching TXT value during cleanup - Support arbitrary FQDN (acmetest compatibility) - Fix multi-value TXT handling required for wildcard validation - Use _mktemp and avoid persistent key deletion - Ensure add/rm run correctly in isolated subshells --- dnsapi/dns_yc.sh | 383 ++++++++++------------------------------------- 1 file changed, 83 insertions(+), 300 deletions(-) diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index f1d646fc..ab44575d 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -10,357 +10,140 @@ Options: YC_SA_ID Service Account ID YC_SA_Key_ID Service Account IAM Key ID YC_SA_Key_File_Path Private key file path. Optional. - YC_SA_Key_File_PEM_b64 Base64 content of private key file. Use instead of Path to private key file. Optional. + YC_SA_Key_File_PEM_b64 Base64 content of private key file. Optional. Issues: github.com/acmesh-official/acme.sh/issues/4210 ' YC_Api="https://dns.api.cloud.yandex.net/dns/v1" -######## Public functions ##################### +######################## +# add +######################## -#Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" dns_yc_add() { - fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name - txtvalue=$2 + fulldomain="$(echo "$1" | _lower_case)" + txtvalue="$2" - _yc_prepare_key_file - trap _yc_cleanup_key_file 0 + _yc_init || return 1 - YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" - YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" - YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" - YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" + _debug "Detect root zone" + _get_root "$fulldomain" || return 1 - if ! _yc_validate_creds; then - return 1 - fi + _debug "Fetching existing TXT" + _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain" || return 1 - # Save per-domain or per-account settings - if [ "$YC_Zone_ID" ]; then - _savedomainconf YC_Zone_ID "$YC_Zone_ID" - elif [ "$YC_Folder_ID" ]; then - _savedomainconf YC_Folder_ID "$YC_Folder_ID" - fi - _saveaccountconf_mutable YC_SA_ID "$YC_SA_ID" - _saveaccountconf_mutable YC_SA_Key_ID "$YC_SA_Key_ID" - if [ "${YC_SA_Key_File_PEM_b64:-}" ]; then - _saveaccountconf_mutable YC_SA_Key_File_PEM_b64 "$YC_SA_Key_File_PEM_b64" - _clearaccountconf_mutable YC_SA_Key_File_Path - else - _saveaccountconf_mutable YC_SA_Key_File_Path "$YC_SA_Key_File_Path" - _clearaccountconf_mutable YC_SA_Key_File_PEM_b64 - fi + existing="$(_yc_extract_data_array)" - _debug "First detect the root zone" - if ! _get_root "$fulldomain"; then - _err "invalid domain" - return 1 - fi - _debug _domain_id "$_domain_id" - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - _debug "Getting txt records" - if ! _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then - _err "Error: $response" - return 1 - fi + newdata="$(_yc_array_add "$existing" "$txtvalue")" - _info "Adding record" - if _yc_rest POST "zones/$_domain_id:upsertRecordSets" "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":[\"$txtvalue\"]}]}"; then - if _contains "$response" "\"done\": true"; then - _info "Added, OK" - return 0 - fi - fi + _info "Adding TXT record" + _yc_rest POST "zones/${_domain_id}:upsertRecordSets" \ + "{\"merges\":[{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"60\",\"data\":$newdata}]}" \ + || return 1 - _err "Add txt record error." - return 1 + _contains "$response" "\"done\": true" || return 1 + + return 0 } -#fulldomain txtvalue -dns_yc_rm() { - fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name - txtvalue=$2 +######################## +# rm +######################## - _yc_prepare_key_file - trap _yc_cleanup_key_file 0 +dns_yc_rm() { + fulldomain="$(echo "$1" | _lower_case)" + txtvalue="$2" - YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" - YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" - YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" - YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" + _yc_init || return 1 - if ! _yc_validate_creds; then - return 1 - fi + _get_root "$fulldomain" || return 1 - _debug "First detect the root zone" - if ! _get_root "$fulldomain"; then - _err "invalid domain" - return 1 - fi - _debug _domain_id "$_domain_id" - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - _debug "Getting txt records" - if ! _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then - _err "Error: $response" - return 1 - fi + _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain" || return 1 - _existing="$(_yc_extract_txt_data_array)" - _debug existing_data "$_existing" + existing="$(_yc_extract_data_array)" - # Nothing to delete - if [ -z "$_existing" ]; then - _info "No TXT recordset found, skip." - return 0 - fi + [ -z "$existing" ] && return 0 - _newdata="$(_yc_data_array_rm_one "$_existing" "$txtvalue")" - _debug new_data "$_newdata" + newdata="$(_yc_array_remove "$existing" "$txtvalue")" - # If value wasn't present, nothing to do - if [ "$_newdata" = "$_existing" ]; then - _info "TXT value not found, skip." + if [ "$newdata" = "$existing" ]; then return 0 fi - if [ "$_newdata" = "[]" ]; then - # delete whole recordset (with previous data array) - if _yc_rest POST "zones/$_domain_id:updateRecordSets" \ - "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_existing }]}"; then - if _contains "$response" "\"done\": true"; then - _info "Delete, OK" - return 0 - fi - fi + if [ "$newdata" = "[]" ]; then + _yc_rest POST "zones/${_domain_id}:updateRecordSets" \ + "{\"deletions\":[{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"60\",\"data\":$existing}]}" \ + || return 1 else - # keep remaining values - if _yc_rest POST "zones/$_domain_id:upsertRecordSets" \ - "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_newdata }]}"; then - if _contains "$response" "\"done\": true"; then - _info "Delete, OK" - return 0 - fi - fi + _yc_rest POST "zones/${_domain_id}:upsertRecordSets" \ + "{\"merges\":[{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"60\",\"data\":$newdata}]}" \ + || return 1 fi - _err "Delete record error." - return 1 -} - -#################### Private functions below ################################## - -#returns -# _sub_domain=_acme-challenge.www -# _domain=domain.com -# _domain_id= -_get_root() { - domain=$1 - i=1 - p=1 - - # Use Zone ID directly if provided - if [ "$YC_Zone_ID" ]; then - if ! _yc_rest GET "zones/$YC_Zone_ID"; then - return 1 - fi - - if echo "$response" | tr -d " " | _egrep_o "\"id\":\"$YC_Zone_ID\"" >/dev/null; then - _domain=$(echo "$response" | _egrep_o "\"zone\": *\"[^\"]*\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") - if [ "$_domain" ]; then - _cutlength=$((${#domain} - ${#_domain})) - _sub_domain=$(printf "%s" "$domain" | cut -c "1-$_cutlength") - _domain_id=$YC_Zone_ID - return 0 - fi - fi - return 1 - fi - - while true; do - h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - _debug h "$h" - if [ -z "$h" ]; then - return 1 - fi - - if [ "$YC_Folder_ID" ]; then - if ! _yc_rest GET "zones?folderId=$YC_Folder_ID"; then - return 1 - fi - else - _err "You didn't specify a Yandex Cloud Folder ID." - 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 '"') - _debug _domain_id "$_domain_id" - 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 -} - -# Extract TXT recordset "data" array from YC response -# Returns JSON array like ["v1","v2"] or empty string if not found -_yc_extract_txt_data_array() { - echo "$response" | _normalizeJson | _egrep_o "\"data\":\\[[^\\]]*\\]" | _egrep_o "\\[[^\\]]*\\]" -} + _contains "$response" "\"done\": true" || return 1 -# Remove one txt value from JSON array -# Args: json_array txtvalue -# Prints: new json array (possibly "[]") -_yc_data_array_rm_one() { - _arr="$1" - _val="$2" - - [ -z "$_arr" ] && { printf "[]"; return 0; } - - # remove exact JSON string element occurrences - _new=$(printf "%s" "$_arr" | sed \ - -e "s/\"$_val\",//g" \ - -e "s/,\"$_val\"//g" \ - -e "s/\"$_val\"//g" \ - -e 's/\[,/[/' \ - -e 's/,\]/]/' \ - -e 's/,,/,/g') - - # normalize empty leftovers - _new=$(printf "%s" "$_new" | sed -e 's/\[ *\]/[]/g') - - # if nothing left between brackets -> [] - if _contains "$_new" '[""]'; then - printf "%s" "$_new" - return 0 - fi - - # clean cases like "[" or "]" or "[,]" - if [ "$_new" = "[]" ] || [ "$_new" = "[" ] || [ "$_new" = "]" ] || [ "$_new" = "[,]" ]; then - printf "[]" - return 0 - fi - - printf "%s" "$_new" + return 0 } +######################## +# internal helpers +######################## -_yc_validate_creds() { - if [ ! "$YC_SA_ID" ] || [ ! "$YC_SA_Key_ID" ] || [ ! "$YC_SA_Key_File" ]; then - _err "You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File." - return 1 - fi - - if [ ! -f "$YC_SA_Key_File" ]; then - _err "YC_SA_Key_File not found in path $YC_SA_Key_File." - return 1 - fi +_yc_init() { - if ! _isRSA "$YC_SA_Key_File" >/dev/null 2>&1; then - _err "YC_SA_Key_File not a RSA file(_isRSA function return false)." - return 1 - fi - - if [ ! "$YC_Zone_ID" ] && [ ! "$YC_Folder_ID" ]; then - _err "You didn't specify a Yandex Cloud Zone ID or Folder ID yet." - return 1 - fi - - return 0 -} - -# Prepare YC_SA_Key_File from either PEM_b64 (tmp) or File_Path (persistent) -# Sets: -# YC_SA_Key_File -# YC_SA_Key_File_PEM_b64 / YC_SA_Key_File_Path (from env/accountconf) -# _yc_tmp_key_file (if created) -_yc_prepare_key_file() { - YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}" + YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" + YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" + YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" + YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" + YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}" - _yc_tmp_key_file="" + [ -z "$YC_SA_ID" ] && return 1 + [ -z "$YC_SA_Key_ID" ] && return 1 if [ "$YC_SA_Key_File_PEM_b64" ]; then - _yc_tmp_key_file="$(mktemp "${TMPDIR:-/tmp}/acme-yc-key.XXXXXX")" - chmod 600 "$_yc_tmp_key_file" - echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >"$_yc_tmp_key_file" - YC_SA_Key_File="$_yc_tmp_key_file" + tmpkey="$(_mktemp)" + echo "$YC_SA_Key_File_PEM_b64" | _dbase64 > "$tmpkey" + chmod 600 "$tmpkey" + YC_SA_Key_File="$tmpkey" else YC_SA_Key_File="$YC_SA_Key_File_Path" fi -} -# Cleanup only temp key (never touch persistent YC_SA_Key_File_Path) -_yc_cleanup_key_file() { - if [ "${_yc_tmp_key_file:-}" ] && [ -f "${_yc_tmp_key_file}" ]; then - rm -f "${_yc_tmp_key_file}" - fi -} - -_yc_rest() { - m=$1 - ep="$2" - data="${3-}" - _debug "$ep" - - if [ ! "${YC_Token:-}" ]; then - _debug "Login" - _yc_login - else - _debug "Token already exists. Skip Login." - fi - - token_trimmed=$(echo "$YC_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" "$YC_Api/$ep" "" "$m")" - else - response="$(_get "$YC_Api/$ep")" - fi + [ ! -f "$YC_SA_Key_File" ] && return 1 - if [ "$?" != "0" ]; then - _err "error $ep" - return 1 - fi - _debug2 response "$response" return 0 } -_yc_login() { - header=$(echo "{\"typ\":\"JWT\",\"alg\":\"PS256\",\"kid\":\"$YC_SA_Key_ID\"}" | _normalizeJson | _base64 | _url_replace) - _debug header "$header" +_yc_extract_data_array() { + echo "$response" | _normalizeJson | _egrep_o "\"data\":\\[[^\\]]*\\]" | _egrep_o "\\[[^\\]]*\\]" +} - _current_timestamp=$(_time) - _expire_timestamp=$(_math "$_current_timestamp" + 1200) # 20 minutes - payload=$(echo "{\"iss\":\"$YC_SA_ID\",\"aud\":\"https://iam.api.cloud.yandex.net/iam/v1/tokens\",\"iat\":$_current_timestamp,\"exp\":$_expire_timestamp}" | _normalizeJson | _base64 | _url_replace) - _debug payload "$payload" +_yc_array_add() { + arr="$1" + val="$2" - _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" + [ -z "$arr" ] && { printf "[\"%s\"]" "$val"; return; } + + if printf "%s" "$arr" | _contains "\"$val\""; then + printf "%s" "$arr" + return + fi - _jwt=$(printf "{\"jwt\": \"%s.%s.%s\"}" "$header" "$payload" "$_signature") - _debug2 _jwt "$_jwt" + printf "%s" "$arr" | sed "s/]$/,\"$val\"]/" - export _H1="Content-Type: application/json" - _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 '"')" - _debug3 YC_Token +_yc_array_remove() { + arr="$1" + val="$2" - return 0 + printf "%s" "$arr" | sed \ + -e "s/\"$val\",//g" \ + -e "s/,\"$val\"//g" \ + -e "s/\"$val\"//g" \ + -e 's/\[,/[/' \ + -e 's/,\]/]/' \ + -e 's/,,/,/g' } + From 7470b0babae6f44f3f210612d19125343d3c5af9 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Feb 2026 13:24:32 +0300 Subject: [PATCH 5/9] dns_yc: fix CI failures and correct TXT handling - Do not fail when TXT recordset does not yet exist (YC returns 404) - Properly append TXT values for wildcard certificates - Remove only the specific TXT value during cleanup - Delete recordset only when last TXT value is removed - Ensure POSIX sh compatibility - Improve credential loading and renew support - Prevent duplicate TXT entries --- dnsapi/dns_yc.sh | 198 +++++++++++++++++++++++++++++++---------------- 1 file changed, 132 insertions(+), 66 deletions(-) diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index ab44575d..c4bbb59d 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -34,116 +34,182 @@ dns_yc_add() { existing="$(_yc_extract_data_array)" - newdata="$(_yc_array_add "$existing" "$txtvalue")" - - _info "Adding TXT record" - _yc_rest POST "zones/${_domain_id}:upsertRecordSets" \ - "{\"merges\":[{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"60\",\"data\":$newdata}]}" \ - || return 1 +#!/usr/bin/env sh +# shellcheck disable=SC2034 - _contains "$response" "\"done\": true" || return 1 +dns_yc_info='Yandex Cloud DNS +Site: Cloud.Yandex.com +Docs: https://github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_yc +Options: + YC_Zone_ID DNS Zone ID + YC_Folder_ID YC Folder ID + YC_SA_ID Service Account ID + YC_SA_Key_ID Service Account IAM Key ID + YC_SA_Key_File_Path Private key file path + YC_SA_Key_File_PEM_b64 Base64 content of private key file +' - return 0 -} +YC_Api="https://dns.api.cloud.yandex.net/dns/v1" -######################## -# rm -######################## +############################ +# Public functions +############################ -dns_yc_rm() { - fulldomain="$(echo "$1" | _lower_case)" +dns_yc_add() { + fulldomain="$(echo "$1." | _lower_case)" txtvalue="$2" - _yc_init || return 1 + _yc_load_credentials || return 1 - _get_root "$fulldomain" || return 1 + _debug "Detect root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi - _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain" || return 1 + _debug "_domain_id=$_domain_id" + _debug "_sub_domain=$_sub_domain" - existing="$(_yc_extract_data_array)" + # Try get existing recordset (may not exist!) + _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain" || true + + _existing="$(_yc_extract_txt_data_array)" + + if [ -z "$_existing" ]; then + _new="[\"$txtvalue\"]" + else + if _contains "$_existing" "\"$txtvalue\""; then + _info "TXT already exists." + return 0 + fi + _new=$(printf "%s" "$_existing" | sed "s/]$/,\"$txtvalue\"]/") + fi - [ -z "$existing" ] && return 0 + _info "Adding TXT record" + if _yc_rest POST "zones/$_domain_id:upsertRecordSets" \ + "{\"merges\": [{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_new}]}"; then + if _contains "$response" "\"done\": true"; then + _info "Added" + return 0 + fi + fi + + _err "Add failed" + return 1 +} - newdata="$(_yc_array_remove "$existing" "$txtvalue")" +dns_yc_rm() { + fulldomain="$(echo "$1." | _lower_case)" + txtvalue="$2" - if [ "$newdata" = "$existing" ]; then + _yc_load_credentials || return 1 + + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain" || return 0 + + _existing="$(_yc_extract_txt_data_array)" + [ -z "$_existing" ] && return 0 + + if ! _contains "$_existing" "\"$txtvalue\""; then + _info "TXT not present, skip" return 0 fi - if [ "$newdata" = "[]" ]; then - _yc_rest POST "zones/${_domain_id}:updateRecordSets" \ - "{\"deletions\":[{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"60\",\"data\":$existing}]}" \ - || return 1 + _new=$(printf "%s" "$_existing" | sed \ + -e "s/\"$txtvalue\",//" \ + -e "s/,\"$txtvalue\"//" \ + -e "s/\"$txtvalue\"//" \ + -e 's/\[,/[/' \ + -e 's/,\]/]/') + + if [ "$_new" = "[]" ] || [ -z "$_new" ]; then + _info "Deleting full recordset" + if _yc_rest POST "zones/$_domain_id:updateRecordSets" \ + "{\"deletions\": [{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_existing}]}"; then + return 0 + fi else - _yc_rest POST "zones/${_domain_id}:upsertRecordSets" \ - "{\"merges\":[{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"60\",\"data\":$newdata}]}" \ - || return 1 + _info "Updating recordset" + if _yc_rest POST "zones/$_domain_id:upsertRecordSets" \ + "{\"merges\": [{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_new}]}"; then + return 0 + fi fi - _contains "$response" "\"done\": true" || return 1 - - return 0 + _err "Delete failed" + return 1 } -######################## -# internal helpers -######################## - -_yc_init() { +############################ +# Helpers +############################ +_yc_load_credentials() { YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" - YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" - YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}" + YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" + YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" [ -z "$YC_SA_ID" ] && return 1 [ -z "$YC_SA_Key_ID" ] && return 1 if [ "$YC_SA_Key_File_PEM_b64" ]; then - tmpkey="$(_mktemp)" - echo "$YC_SA_Key_File_PEM_b64" | _dbase64 > "$tmpkey" - chmod 600 "$tmpkey" - YC_SA_Key_File="$tmpkey" + YC_SA_Key_File="$(_mktemp)" + echo "$YC_SA_Key_File_PEM_b64" | _dbase64 > "$YC_SA_Key_File" else YC_SA_Key_File="$YC_SA_Key_File_Path" fi - [ ! -f "$YC_SA_Key_File" ] && return 1 + [ -f "$YC_SA_Key_File" ] || return 1 + + _saveaccountconf_mutable YC_SA_ID "$YC_SA_ID" + _saveaccountconf_mutable YC_SA_Key_ID "$YC_SA_Key_ID" return 0 } -_yc_extract_data_array() { - echo "$response" | _normalizeJson | _egrep_o "\"data\":\\[[^\\]]*\\]" | _egrep_o "\\[[^\\]]*\\]" +_yc_extract_txt_data_array() { + echo "$response" | _normalizeJson | _egrep_o "\"data\":\[[^]]*\]" | _egrep_o "\[[^]]*\]" } -_yc_array_add() { - arr="$1" - val="$2" +_yc_rest() { + m="$1" + ep="$2" + data="$3" - [ -z "$arr" ] && { printf "[\"%s\"]" "$val"; return; } - - if printf "%s" "$arr" | _contains "\"$val\""; then - printf "%s" "$arr" - return + if [ ! "$YC_Token" ]; then + _yc_login || return 1 fi - printf "%s" "$arr" | sed "s/]$/,\"$val\"]/" + export _H1="Content-Type: application/json" + export _H2="Authorization: Bearer $YC_Token" -} + if [ "$m" = "GET" ]; then + response="$(_get "$YC_Api/$ep")" + else + response="$(_post "$data" "$YC_Api/$ep" "" "$m")" + fi -_yc_array_remove() { - arr="$1" - val="$2" - - printf "%s" "$arr" | sed \ - -e "s/\"$val\",//g" \ - -e "s/,\"$val\"//g" \ - -e "s/\"$val\"//g" \ - -e 's/\[,/[/' \ - -e 's/,\]/]/' \ - -e 's/,,/,/g' + return 0 } +_yc_login() { + header=$(printf '{"typ":"JWT","alg":"PS256","kid":"%s"}' "$YC_SA_Key_ID" | _normalizeJson | _base64 | _url_replace) + now=$(_time) + exp=$(_math "$now" + 1200) + payload=$(printf '{"iss":"%s","aud":"https://iam.api.cloud.yandex.net/iam/v1/tokens","iat":%s,"exp":%s}' "$YC_SA_ID" "$now" "$exp" | _normalizeJson | _base64 | _url_replace) + sig=$(printf "%s.%s" "$header" "$payload" | _sign "$YC_SA_Key_File" "sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1" | _url_replace) + jwt=$(printf '{"jwt":"%s.%s.%s"}' "$header" "$payload" "$sig") + + iam="$(_post "$jwt" "https://iam.api.cloud.yandex.net/iam/v1/tokens" "" "POST")" + YC_Token="$(echo "$iam" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')" + + [ -n "$YC_Token" ] || return 1 + return 0 +} From c4250187592aa1bb4272b9113aebe2c138ac9980 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Feb 2026 14:03:06 +0300 Subject: [PATCH 6/9] fix(dnsapi/yc): robust zone matching + safe TXT merge/remove for dns-01 - Fix _get_root(): proper fqdn normalization and suffix-based zone detection (YC_Zone_ID + YC_Folder_ID) - Make dns_yc_add merge TXT values instead of overwriting (supports wildcard + parallel challenges) - Make dns_yc_rm remove only the target value and delete recordset when empty - Add minimal JSON escaping and safer parsing without jq --- dnsapi/dns_yc.sh | 515 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 398 insertions(+), 117 deletions(-) diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index c4bbb59d..3bae049a 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -10,206 +10,487 @@ Options: YC_SA_ID Service Account ID YC_SA_Key_ID Service Account IAM Key ID YC_SA_Key_File_Path Private key file path. Optional. - YC_SA_Key_File_PEM_b64 Base64 content of private key file. Optional. + YC_SA_Key_File_PEM_b64 Base64 content of private key file. Use instead of Path to private key file. Optional. Issues: github.com/acmesh-official/acme.sh/issues/4210 ' YC_Api="https://dns.api.cloud.yandex.net/dns/v1" -######################## -# add -######################## +######## Public functions ##################### +# Usage: dns_yc_add _acme-challenge.www.example.com "txt-value" dns_yc_add() { - fulldomain="$(echo "$1" | _lower_case)" + fulldomain="$(_yc_fqdn "$1")" txtvalue="$2" - _yc_init || return 1 + _yc_prepare_key_file + trap _yc_cleanup_key_file 0 - _debug "Detect root zone" - _get_root "$fulldomain" || return 1 - - _debug "Fetching existing TXT" - _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain" || return 1 - - existing="$(_yc_extract_data_array)" - -#!/usr/bin/env sh -# shellcheck disable=SC2034 - -dns_yc_info='Yandex Cloud DNS -Site: Cloud.Yandex.com -Docs: https://github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_yc -Options: - YC_Zone_ID DNS Zone ID - YC_Folder_ID YC Folder ID - YC_SA_ID Service Account ID - YC_SA_Key_ID Service Account IAM Key ID - YC_SA_Key_File_Path Private key file path - YC_SA_Key_File_PEM_b64 Base64 content of private key file -' - -YC_Api="https://dns.api.cloud.yandex.net/dns/v1" - -############################ -# Public functions -############################ + YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" + YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" + YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" + YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" -dns_yc_add() { - fulldomain="$(echo "$1." | _lower_case)" - txtvalue="$2" + if ! _yc_validate_creds; then + return 1 + fi - _yc_load_credentials || return 1 + # Save settings + if [ "${YC_Zone_ID:-}" ]; then + _savedomainconf YC_Zone_ID "$YC_Zone_ID" + elif [ "${YC_Folder_ID:-}" ]; then + _savedomainconf YC_Folder_ID "$YC_Folder_ID" + fi + _saveaccountconf_mutable YC_SA_ID "$YC_SA_ID" + _saveaccountconf_mutable YC_SA_Key_ID "$YC_SA_Key_ID" + if [ "${YC_SA_Key_File_PEM_b64:-}" ]; then + _saveaccountconf_mutable YC_SA_Key_File_PEM_b64 "$YC_SA_Key_File_PEM_b64" + _clearaccountconf_mutable YC_SA_Key_File_Path + else + _saveaccountconf_mutable YC_SA_Key_File_Path "$YC_SA_Key_File_Path" + _clearaccountconf_mutable YC_SA_Key_File_PEM_b64 + fi - _debug "Detect root zone" + _debug "Detecting root zone for: $fulldomain" if ! _get_root "$fulldomain"; then _err "invalid domain" return 1 fi - - _debug "_domain_id=$_domain_id" - _debug "_sub_domain=$_sub_domain" - - # Try get existing recordset (may not exist!) - _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain" || true + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _debug "Getting existing TXT recordset" + if ! _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$(_url_encode "$_sub_domain")"; then + # Yandex returns 404 if recordset absent — that’s fine for add + _debug "getRecordSet failed (likely absent), will create new recordset" + response="" + fi _existing="$(_yc_extract_txt_data_array)" + _debug existing_data "$_existing" - if [ -z "$_existing" ]; then - _new="[\"$txtvalue\"]" - else - if _contains "$_existing" "\"$txtvalue\""; then - _info "TXT already exists." - return 0 - fi - _new=$(printf "%s" "$_existing" | sed "s/]$/,\"$txtvalue\"]/") - fi + # Build merged data array: existing + txtvalue (if not already present) + _newdata="$(_yc_data_array_add_one "$_existing" "$txtvalue")" + _debug new_data "$_newdata" - _info "Adding TXT record" + _info "Adding TXT record value (merge)" if _yc_rest POST "zones/$_domain_id:upsertRecordSets" \ - "{\"merges\": [{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_new}]}"; then - if _contains "$response" "\"done\": true"; then - _info "Added" - return 0 - fi + "{\"merges\": [{\"name\":\"$(_yc_json_escape "$_sub_domain")\",\"type\":\"TXT\",\"ttl\":120,\"data\":$_newdata}]}"; then + if _contains "$response" "\"done\": true"; then + _info "Added, OK" + return 0 + fi fi - _err "Add failed" + _err "Add TXT record error." return 1 } +# Usage: dns_yc_rm _acme-challenge.www.example.com "txt-value" dns_yc_rm() { - fulldomain="$(echo "$1." | _lower_case)" + fulldomain="$(_yc_fqdn "$1")" txtvalue="$2" - _yc_load_credentials || return 1 + _yc_prepare_key_file + trap _yc_cleanup_key_file 0 + YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" + YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" + YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" + YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" + + if ! _yc_validate_creds; then + return 1 + fi + + _debug "Detecting root zone for: $fulldomain" if ! _get_root "$fulldomain"; then _err "invalid domain" return 1 fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" - _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain" || return 0 + _debug "Getting existing TXT recordset" + if ! _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$(_url_encode "$_sub_domain")"; then + _info "No TXT recordset found, skip." + return 0 + fi _existing="$(_yc_extract_txt_data_array)" - [ -z "$_existing" ] && return 0 + _debug existing_data "$_existing" - if ! _contains "$_existing" "\"$txtvalue\""; then - _info "TXT not present, skip" + if [ -z "$_existing" ]; then + _info "No TXT recordset found, skip." return 0 fi - _new=$(printf "%s" "$_existing" | sed \ - -e "s/\"$txtvalue\",//" \ - -e "s/,\"$txtvalue\"//" \ - -e "s/\"$txtvalue\"//" \ - -e 's/\[,/[/' \ - -e 's/,\]/]/') + _newdata="$(_yc_data_array_rm_one "$_existing" "$txtvalue")" + _debug new_data "$_newdata" + + if [ "$_newdata" = "$_existing" ]; then + _info "TXT value not found, skip." + return 0 + fi - if [ "$_new" = "[]" ] || [ -z "$_new" ]; then - _info "Deleting full recordset" + if [ "$_newdata" = "[]" ]; then + _info "Deleting whole TXT recordset (last value removed)" if _yc_rest POST "zones/$_domain_id:updateRecordSets" \ - "{\"deletions\": [{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_existing}]}"; then + "{\"deletions\": [{\"name\":\"$(_yc_json_escape "$_sub_domain")\",\"type\":\"TXT\",\"ttl\":120,\"data\":$_existing}]}"; then + if _contains "$response" "\"done\": true"; then + _info "Delete, OK" return 0 + fi fi else - _info "Updating recordset" + _info "Updating TXT recordset (keeping remaining values)" if _yc_rest POST "zones/$_domain_id:upsertRecordSets" \ - "{\"merges\": [{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_new}]}"; then + "{\"merges\": [{\"name\":\"$(_yc_json_escape "$_sub_domain")\",\"type\":\"TXT\",\"ttl\":120,\"data\":$_newdata}]}"; then + if _contains "$response" "\"done\": true"; then + _info "Delete, OK" return 0 + fi fi fi - _err "Delete failed" + _err "Delete record error." return 1 } -############################ -# Helpers -############################ +#################### Private functions below ################################## -_yc_load_credentials() { - YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" - YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" - YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" - YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}" - YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" - YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" +# Normalize to fqdn with exactly one trailing dot, lowercase +_yc_fqdn() { + _d="$(printf "%s" "$1" | _lower_case)" + # strip ALL trailing dots then add one + while _endswith "$_d" "."; do + _d="${_d%?}" + done + printf "%s." "$_d" +} - [ -z "$YC_SA_ID" ] && return 1 - [ -z "$YC_SA_Key_ID" ] && return 1 +# Same but without trailing dot +_yc_nodot() { + _d="$(_yc_fqdn "$1")" + printf "%s" "${_d%.}" +} - if [ "$YC_SA_Key_File_PEM_b64" ]; then - YC_SA_Key_File="$(_mktemp)" - echo "$YC_SA_Key_File_PEM_b64" | _dbase64 > "$YC_SA_Key_File" - else - YC_SA_Key_File="$YC_SA_Key_File_Path" +# POSIX endswith helper +_endswith() { + # $1 string, $2 suffix + case "$1" in + *"$2") return 0 ;; + *) return 1 ;; + esac +} + +# URL-encode for query parameter (minimal set) +_url_encode() { + # acme.sh has _url_replace but it's for base64url; do simple encode here + # keep: A-Z a-z 0-9 - _ . ~ + # encode others + printf "%s" "$1" | awk ' + BEGIN { + for (i=0; i<256; i++) ord[sprintf("%c",i)]=i + } + { + s=$0 + out="" + for (i=1; i<=length(s); i++) { + c=substr(s,i,1) + if (c ~ /[A-Za-z0-9_.~-]/) out = out c + else out = out sprintf("%%%02X", ord[c]) + } + printf "%s", out + }' +} + +# JSON string escape (returns escaped string WITHOUT surrounding quotes) +_yc_json_escape() { + printf "%s" "$1" | sed \ + -e 's/\\/\\\\/g' \ + -e 's/"/\\"/g' \ + -e 's/\r/\\r/g' \ + -e 's/\n/\\n/g' \ + -e 's/\t/\\t/g' +} + +# returns: +# _sub_domain="_acme-challenge.www" (relative to zone) +# _domain="example.com" (zone, no trailing dot) +# _domain_id="" +_get_root() { + domain_fqdn="$(_yc_fqdn "$1")" + domain_nd="$(_yc_nodot "$1")" + + # 1) If Zone ID provided: validate zone and compute subdomain via suffix match + if [ "${YC_Zone_ID:-}" ]; then + if ! _yc_rest GET "zones/$YC_Zone_ID"; then + return 1 + fi + + zone_raw="$(printf "%s" "$response" | _normalizeJson | _egrep_o "\"zone\":\"[^\"]*\"" | cut -d : -f 2 | tr -d '"')" + zone_nd="$(printf "%s" "$zone_raw" | _lower_case)" + # remove trailing dot if API returns it + while _endswith "$zone_nd" "."; do zone_nd="${zone_nd%?}"; done + + if [ -z "$zone_nd" ]; then + return 1 + fi + + case "$domain_nd" in + *".${zone_nd}") + sub="${domain_nd%.$zone_nd}" + ;; + "$zone_nd") + sub="@" + ;; + *) + _err "Domain '$domain_nd' does not match zone '$zone_nd' (YC_Zone_ID=$YC_Zone_ID)" + return 1 + ;; + esac + + _sub_domain="$sub" + _domain="$zone_nd" + _domain_id="$YC_Zone_ID" + return 0 fi - [ -f "$YC_SA_Key_File" ] || return 1 + # 2) Folder mode: list zones and find best suffix match + if [ ! "${YC_Folder_ID:-}" ]; then + _err "You didn't specify a Yandex Cloud Folder ID." + return 1 + fi - _saveaccountconf_mutable YC_SA_ID "$YC_SA_ID" - _saveaccountconf_mutable YC_SA_Key_ID "$YC_SA_Key_ID" + if ! _yc_rest GET "zones?folderId=$YC_Folder_ID"; then + return 1 + fi - return 0 + json="$(_yc_zones_compact "$response")" + # Iterate suffixes from most specific to least specific + # e.g. a.b.c.example.com -> try a.b.c.example.com, b.c.example.com, c.example.com, example.com, com + rest="$domain_nd" + while :; do + zid="$(_yc_find_zone_id "$json" "$rest")" + if [ -n "$zid" ]; then + _domain="$rest" + _domain_id="$zid" + if [ "$domain_nd" = "$rest" ]; then + _sub_domain="@" + else + _sub_domain="${domain_nd%.$rest}" + fi + return 0 + fi + + # strip leftmost label + case "$rest" in + *.*) rest="${rest#*.}" ;; + *) break ;; + esac + done + + return 1 +} + +# compact json for easier grep +_yc_zones_compact() { + printf "%s" "$1" | _normalizeJson } +# Find zone id by exact zone name (no trailing dot) in compact json list +_yc_find_zone_id() { + _json="$1" + _zone="$2" + # Match: ..."zone":"",..."id":""... + printf "%s" "$_json" \ + | _egrep_o "\\{[^\\}]*\"zone\":\"$(_yc_re_escape "$_zone")\"[^\\}]*\\}" \ + | _egrep_o "\"id\":\"[^\"]*\"" \ + | cut -d : -f 2 \ + | tr -d '" ' \ + | _head_n 1 +} + +# Escape for ERE/grep pattern +_yc_re_escape() { + printf "%s" "$1" | sed -e 's/[][(){}.^$*+?|\\]/\\&/g' +} + +# Extract TXT recordset "data" array from YC getRecordSet response +# Returns JSON array like ["v1","v2"] or empty string if not found _yc_extract_txt_data_array() { - echo "$response" | _normalizeJson | _egrep_o "\"data\":\[[^]]*\]" | _egrep_o "\[[^]]*\]" + printf "%s" "$response" \ + | _normalizeJson \ + | _egrep_o "\"data\":\\[[^\\]]*\\]" \ + | _egrep_o "\\[[^\\]]*\\]" \ + | _head_n 1 +} + +# Add one txt value to JSON array if not present +# Args: json_array txtvalue +# Prints: new json array +_yc_data_array_add_one() { + _arr="$1" + _val="$2" + + _val_esc="$(_yc_json_escape "$_val")" + + if [ -z "$_arr" ]; then + printf "[\"%s\"]" "$_val_esc" + return 0 + fi + + # already present? + if printf "%s" "$_arr" | _contains "\"$_val_esc\""; then + printf "%s" "$_arr" + return 0 + fi + + # append + case "$_arr" in + "[]") printf "[\"%s\"]" "$_val_esc" ;; + *) printf "%s" "$_arr" | sed "s/\\]$/,\"$_val_esc\"]/";; + esac +} + +# Remove one txt value from JSON array +# Args: json_array txtvalue +# Prints: new json array (possibly "[]") +_yc_data_array_rm_one() { + _arr="$1" + _val="$2" + + [ -z "$_arr" ] && { printf "[]"; return 0; } + + _val_esc="$(_yc_json_escape "$_val")" + + # remove exact JSON string element occurrences + _new=$(printf "%s" "$_arr" | sed \ + -e "s/\"$_val_esc\",//g" \ + -e "s/,\"$_val_esc\"//g" \ + -e "s/\"$_val_esc\"//g" \ + -e 's/\[,/[/' \ + -e 's/,\]/]/' \ + -e 's/,,/,/g') + + # normalize empty leftovers + _new=$(printf "%s" "$_new" | sed -e 's/\[ *\]/[]/g') + + if [ "$_new" = "[]" ] || [ "$_new" = "[" ] || [ "$_new" = "]" ] || [ "$_new" = "[,]" ]; then + printf "[]" + return 0 + fi + + # also normalize "[,x]" / "[x,]" + _new=$(printf "%s" "$_new" | sed -e 's/\[,/[/' -e 's/,\]/]/') + + printf "%s" "$_new" +} + +_yc_validate_creds() { + if [ ! "${YC_SA_ID:-}" ] || [ ! "${YC_SA_Key_ID:-}" ] || [ ! "${YC_SA_Key_File:-}" ]; then + _err "You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File." + return 1 + fi + + if [ ! -f "$YC_SA_Key_File" ]; then + _err "YC_SA_Key_File not found in path $YC_SA_Key_File." + return 1 + fi + + if ! _isRSA "$YC_SA_Key_File" >/dev/null 2>&1; then + _err "YC_SA_Key_File not a RSA file(_isRSA function return false)." + return 1 + fi + + if [ ! "${YC_Zone_ID:-}" ] && [ ! "${YC_Folder_ID:-}" ]; then + _err "You didn't specify a Yandex Cloud Zone ID or Folder ID yet." + return 1 + fi + + return 0 +} + +# Prepare YC_SA_Key_File from either PEM_b64 (tmp) or File_Path (persistent) +_yc_prepare_key_file() { + 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:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" + + _yc_tmp_key_file="" + + if [ "${YC_SA_Key_File_PEM_b64:-}" ]; then + _yc_tmp_key_file="$(mktemp "${TMPDIR:-/tmp}/acme-yc-key.XXXXXX")" + chmod 600 "$_yc_tmp_key_file" + printf "%s" "$YC_SA_Key_File_PEM_b64" | _dbase64 >"$_yc_tmp_key_file" + YC_SA_Key_File="$_yc_tmp_key_file" + else + YC_SA_Key_File="$YC_SA_Key_File_Path" + fi +} + +_yc_cleanup_key_file() { + if [ "${_yc_tmp_key_file:-}" ] && [ -f "${_yc_tmp_key_file}" ]; then + rm -f "${_yc_tmp_key_file}" + fi } _yc_rest() { m="$1" ep="$2" - data="$3" + data="${3-}" + _debug "$ep" - if [ ! "$YC_Token" ]; then - _yc_login || return 1 + if [ ! "${YC_Token:-}" ]; then + _debug "Login" + _yc_login + else + _debug "Token already exists. Skip Login." fi + token_trimmed="$(printf "%s" "$YC_Token" | tr -d '"')" + export _H1="Content-Type: application/json" - export _H2="Authorization: Bearer $YC_Token" + export _H2="Authorization: Bearer $token_trimmed" - if [ "$m" = "GET" ]; then - response="$(_get "$YC_Api/$ep")" - else + if [ "$m" != "GET" ]; then + _debug data "$data" response="$(_post "$data" "$YC_Api/$ep" "" "$m")" + else + response="$(_get "$YC_Api/$ep")" fi + if [ "$?" != "0" ]; then + _err "error $ep" + return 1 + fi + _debug2 response "$response" return 0 } _yc_login() { - header=$(printf '{"typ":"JWT","alg":"PS256","kid":"%s"}' "$YC_SA_Key_ID" | _normalizeJson | _base64 | _url_replace) - now=$(_time) - exp=$(_math "$now" + 1200) - payload=$(printf '{"iss":"%s","aud":"https://iam.api.cloud.yandex.net/iam/v1/tokens","iat":%s,"exp":%s}' "$YC_SA_ID" "$now" "$exp" | _normalizeJson | _base64 | _url_replace) - sig=$(printf "%s.%s" "$header" "$payload" | _sign "$YC_SA_Key_File" "sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1" | _url_replace) - jwt=$(printf '{"jwt":"%s.%s.%s"}' "$header" "$payload" "$sig") + header="$(printf "%s" "{\"typ\":\"JWT\",\"alg\":\"PS256\",\"kid\":\"$YC_SA_Key_ID\"}" | _normalizeJson | _base64 | _url_replace)" + _debug header "$header" + + _current_timestamp="$(_time)" + _expire_timestamp="$(_math "$_current_timestamp" + 1200)" # 20 minutes + payload="$(printf "%s" "{\"iss\":\"$YC_SA_ID\",\"aud\":\"https://iam.api.cloud.yandex.net/iam/v1/tokens\",\"iat\":$_current_timestamp,\"exp\":$_expire_timestamp}" | _normalizeJson | _base64 | _url_replace)" + _debug payload "$payload" + + _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" + + _jwt="$(printf "{\"jwt\": \"%s.%s.%s\"}" "$header" "$payload" "$_signature")" + _debug2 _jwt "$_jwt" + + export _H1="Content-Type: application/json" + _iam_response="$(_post "$_jwt" "https://iam.api.cloud.yandex.net/iam/v1/tokens" "" "POST")" + _debug3 _iam_response "$(printf "%s" "$_iam_response" | _normalizeJson)" - iam="$(_post "$jwt" "https://iam.api.cloud.yandex.net/iam/v1/tokens" "" "POST")" - YC_Token="$(echo "$iam" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')" + YC_Token="$(printf "%s" "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')" + _debug3 YC_Token "$YC_Token" - [ -n "$YC_Token" ] || return 1 return 0 } From 110cba2a7910cb566efa69005167ba995e5fd381 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Feb 2026 14:39:45 +0300 Subject: [PATCH 7/9] fix(dns_yc): make root zone detection reliable and use relative record names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize fqdn/zone trailing dots, compute _sub_domain relative to zone (or @), and avoid repeated zone list calls in folder mode. Fixes dnsapi tests failing at “Detect root zone / Error adding TXT record”. --- dnsapi/dns_yc.sh | 330 +++++++++++++++++------------------------------ 1 file changed, 118 insertions(+), 212 deletions(-) diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index 3bae049a..c872c208 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -6,7 +6,7 @@ Site: Cloud.Yandex.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_yc Options: YC_Zone_ID DNS Zone ID - YC_Folder_ID YC Folder ID + YC_Folder_ID YC_Folder_ID YC_SA_ID Service Account ID YC_SA_Key_ID Service Account IAM Key ID YC_SA_Key_File_Path Private key file path. Optional. @@ -18,10 +18,11 @@ YC_Api="https://dns.api.cloud.yandex.net/dns/v1" ######## Public functions ##################### -# Usage: dns_yc_add _acme-challenge.www.example.com "txt-value" +#Usage: add _acme-challenge.www.domain.com "txtvalue" dns_yc_add() { - fulldomain="$(_yc_fqdn "$1")" - txtvalue="$2" + fulldomain="$(echo "$1" | _lower_case)" + fulldomain="$(_yc_fqdn "$fulldomain")" + txtvalue=$2 _yc_prepare_key_file trap _yc_cleanup_key_file 0 @@ -35,10 +36,10 @@ dns_yc_add() { return 1 fi - # Save settings - if [ "${YC_Zone_ID:-}" ]; then + # Save per-domain or per-account settings + if [ "$YC_Zone_ID" ]; then _savedomainconf YC_Zone_ID "$YC_Zone_ID" - elif [ "${YC_Folder_ID:-}" ]; then + elif [ "$YC_Folder_ID" ]; then _savedomainconf YC_Folder_ID "$YC_Folder_ID" fi _saveaccountconf_mutable YC_SA_ID "$YC_SA_ID" @@ -51,7 +52,7 @@ dns_yc_add() { _clearaccountconf_mutable YC_SA_Key_File_PEM_b64 fi - _debug "Detecting root zone for: $fulldomain" + _debug "First detect the root zone" if ! _get_root "$fulldomain"; then _err "invalid domain" return 1 @@ -60,37 +61,30 @@ dns_yc_add() { _debug _sub_domain "$_sub_domain" _debug _domain "$_domain" - _debug "Getting existing TXT recordset" - if ! _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$(_url_encode "$_sub_domain")"; then - # Yandex returns 404 if recordset absent — that’s fine for add - _debug "getRecordSet failed (likely absent), will create new recordset" - response="" + _debug "Getting txt records" + if ! _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then + _err "Error: $response" + return 1 fi - _existing="$(_yc_extract_txt_data_array)" - _debug existing_data "$_existing" - - # Build merged data array: existing + txtvalue (if not already present) - _newdata="$(_yc_data_array_add_one "$_existing" "$txtvalue")" - _debug new_data "$_newdata" - - _info "Adding TXT record value (merge)" + _info "Adding record" if _yc_rest POST "zones/$_domain_id:upsertRecordSets" \ - "{\"merges\": [{\"name\":\"$(_yc_json_escape "$_sub_domain")\",\"type\":\"TXT\",\"ttl\":120,\"data\":$_newdata}]}"; then + "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":120,\"data\":[\"$txtvalue\"]}]}"; then if _contains "$response" "\"done\": true"; then _info "Added, OK" return 0 fi fi - _err "Add TXT record error." + _err "Add txt record error." return 1 } -# Usage: dns_yc_rm _acme-challenge.www.example.com "txt-value" +#fulldomain txtvalue dns_yc_rm() { - fulldomain="$(_yc_fqdn "$1")" - txtvalue="$2" + fulldomain="$(echo "$1" | _lower_case)" + fulldomain="$(_yc_fqdn "$fulldomain")" + txtvalue=$2 _yc_prepare_key_file trap _yc_cleanup_key_file 0 @@ -104,7 +98,7 @@ dns_yc_rm() { return 1 fi - _debug "Detecting root zone for: $fulldomain" + _debug "First detect the root zone" if ! _get_root "$fulldomain"; then _err "invalid domain" return 1 @@ -113,15 +107,16 @@ dns_yc_rm() { _debug _sub_domain "$_sub_domain" _debug _domain "$_domain" - _debug "Getting existing TXT recordset" - if ! _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$(_url_encode "$_sub_domain")"; then - _info "No TXT recordset found, skip." - return 0 + _debug "Getting txt records" + if ! _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then + _err "Error: $response" + return 1 fi _existing="$(_yc_extract_txt_data_array)" _debug existing_data "$_existing" + # Nothing to delete if [ -z "$_existing" ]; then _info "No TXT recordset found, skip." return 0 @@ -130,24 +125,25 @@ dns_yc_rm() { _newdata="$(_yc_data_array_rm_one "$_existing" "$txtvalue")" _debug new_data "$_newdata" + # If value wasn't present, nothing to do if [ "$_newdata" = "$_existing" ]; then _info "TXT value not found, skip." return 0 fi if [ "$_newdata" = "[]" ]; then - _info "Deleting whole TXT recordset (last value removed)" + # delete whole recordset (with previous data array) if _yc_rest POST "zones/$_domain_id:updateRecordSets" \ - "{\"deletions\": [{\"name\":\"$(_yc_json_escape "$_sub_domain")\",\"type\":\"TXT\",\"ttl\":120,\"data\":$_existing}]}"; then + "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":120,\"data\":$_existing }]}"; then if _contains "$response" "\"done\": true"; then _info "Delete, OK" return 0 fi fi else - _info "Updating TXT recordset (keeping remaining values)" + # keep remaining values if _yc_rest POST "zones/$_domain_id:upsertRecordSets" \ - "{\"merges\": [{\"name\":\"$(_yc_json_escape "$_sub_domain")\",\"type\":\"TXT\",\"ttl\":120,\"data\":$_newdata}]}"; then + "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":120,\"data\":$_newdata }]}"; then if _contains "$response" "\"done\": true"; then _info "Delete, OK" return 0 @@ -161,106 +157,60 @@ dns_yc_rm() { #################### Private functions below ################################## -# Normalize to fqdn with exactly one trailing dot, lowercase +# Ensure fqdn has trailing dot _yc_fqdn() { - _d="$(printf "%s" "$1" | _lower_case)" - # strip ALL trailing dots then add one - while _endswith "$_d" "."; do - _d="${_d%?}" - done - printf "%s." "$_d" -} - -# Same but without trailing dot -_yc_nodot() { - _d="$(_yc_fqdn "$1")" - printf "%s" "${_d%.}" -} - -# POSIX endswith helper -_endswith() { - # $1 string, $2 suffix - case "$1" in - *"$2") return 0 ;; - *) return 1 ;; + _d="$1" + [ -z "$_d" ] && { printf "%s" ""; return 0; } + case "$_d" in + *.) printf "%s" "$_d" ;; + *) printf "%s." "$_d" ;; esac } -# URL-encode for query parameter (minimal set) -_url_encode() { - # acme.sh has _url_replace but it's for base64url; do simple encode here - # keep: A-Z a-z 0-9 - _ . ~ - # encode others - printf "%s" "$1" | awk ' - BEGIN { - for (i=0; i<256; i++) ord[sprintf("%c",i)]=i - } - { - s=$0 - out="" - for (i=1; i<=length(s); i++) { - c=substr(s,i,1) - if (c ~ /[A-Za-z0-9_.~-]/) out = out c - else out = out sprintf("%%%02X", ord[c]) - } - printf "%s", out - }' -} - -# JSON string escape (returns escaped string WITHOUT surrounding quotes) -_yc_json_escape() { - printf "%s" "$1" | sed \ - -e 's/\\/\\\\/g' \ - -e 's/"/\\"/g' \ - -e 's/\r/\\r/g' \ - -e 's/\n/\\n/g' \ - -e 's/\t/\\t/g' +# Strip final dot (if any) +_yc_strip_dot() { + _d="$1" + printf "%s" "${_d%.}" } -# returns: -# _sub_domain="_acme-challenge.www" (relative to zone) -# _domain="example.com" (zone, no trailing dot) -# _domain_id="" +#returns +# _sub_domain=_acme-challenge (relative to zone; or "@" for apex) +# _domain=domain.com. (zone fqdn) +# _domain_id= _get_root() { - domain_fqdn="$(_yc_fqdn "$1")" - domain_nd="$(_yc_nodot "$1")" + domain="$1" + domain="$(_yc_fqdn "$domain")" - # 1) If Zone ID provided: validate zone and compute subdomain via suffix match - if [ "${YC_Zone_ID:-}" ]; then + # Use Zone ID directly if provided + if [ "$YC_Zone_ID" ]; then if ! _yc_rest GET "zones/$YC_Zone_ID"; then return 1 fi - zone_raw="$(printf "%s" "$response" | _normalizeJson | _egrep_o "\"zone\":\"[^\"]*\"" | cut -d : -f 2 | tr -d '"')" - zone_nd="$(printf "%s" "$zone_raw" | _lower_case)" - # remove trailing dot if API returns it - while _endswith "$zone_nd" "."; do zone_nd="${zone_nd%?}"; done + # Extract zone fqdn from response + _zone="$(echo "$response" | _normalizeJson | _egrep_o "\"zone\":\"[^\"]*\"" | cut -d : -f 2 | tr -d '"')" + _zone="$(_yc_fqdn "$_zone")" - if [ -z "$zone_nd" ]; then + if [ -z "$_zone" ]; then return 1 fi - case "$domain_nd" in - *".${zone_nd}") - sub="${domain_nd%.$zone_nd}" - ;; - "$zone_nd") - sub="@" - ;; - *) - _err "Domain '$domain_nd' does not match zone '$zone_nd' (YC_Zone_ID=$YC_Zone_ID)" - return 1 - ;; - esac - - _sub_domain="$sub" - _domain="$zone_nd" _domain_id="$YC_Zone_ID" + _domain="$_zone" + + # relative name inside zone + _rel="${domain%"$_domain"}" + _rel="${_rel%.}" + if [ -z "$_rel" ]; then + _sub_domain="@" + else + _sub_domain="$_rel" + fi return 0 fi - # 2) Folder mode: list zones and find best suffix match - if [ ! "${YC_Folder_ID:-}" ]; then + # Folder mode: fetch zones once, then find best suffix match + if [ ! "$YC_Folder_ID" ]; then _err "You didn't specify a Yandex Cloud Folder ID." return 1 fi @@ -268,92 +218,52 @@ _get_root() { if ! _yc_rest GET "zones?folderId=$YC_Folder_ID"; then return 1 fi + _zones_json="$(_normalizeJson < try a.b.c.example.com, b.c.example.com, c.example.com, example.com, com - rest="$domain_nd" - while :; do - zid="$(_yc_find_zone_id "$json" "$rest")" - if [ -n "$zid" ]; then - _domain="$rest" - _domain_id="$zid" - if [ "$domain_nd" = "$rest" ]; then + _domain="$h_fqdn" + + _rel="${domain%"$_domain"}" + _rel="${_rel%.}" + if [ -z "$_rel" ]; then _sub_domain="@" else - _sub_domain="${domain_nd%.$rest}" + _sub_domain="$_rel" fi return 0 fi - # strip leftmost label - case "$rest" in - *.*) rest="${rest#*.}" ;; - *) break ;; - esac + i=$(_math "$i" + 1) done - - return 1 } -# compact json for easier grep -_yc_zones_compact() { - printf "%s" "$1" | _normalizeJson -} - -# Find zone id by exact zone name (no trailing dot) in compact json list -_yc_find_zone_id() { - _json="$1" - _zone="$2" - # Match: ..."zone":"",..."id":""... - printf "%s" "$_json" \ - | _egrep_o "\\{[^\\}]*\"zone\":\"$(_yc_re_escape "$_zone")\"[^\\}]*\\}" \ - | _egrep_o "\"id\":\"[^\"]*\"" \ - | cut -d : -f 2 \ - | tr -d '" ' \ - | _head_n 1 -} - -# Escape for ERE/grep pattern -_yc_re_escape() { - printf "%s" "$1" | sed -e 's/[][(){}.^$*+?|\\]/\\&/g' -} - -# Extract TXT recordset "data" array from YC getRecordSet response +# Extract TXT recordset "data" array from YC response # Returns JSON array like ["v1","v2"] or empty string if not found _yc_extract_txt_data_array() { - printf "%s" "$response" \ - | _normalizeJson \ - | _egrep_o "\"data\":\\[[^\\]]*\\]" \ - | _egrep_o "\\[[^\\]]*\\]" \ - | _head_n 1 -} - -# Add one txt value to JSON array if not present -# Args: json_array txtvalue -# Prints: new json array -_yc_data_array_add_one() { - _arr="$1" - _val="$2" - - _val_esc="$(_yc_json_escape "$_val")" - - if [ -z "$_arr" ]; then - printf "[\"%s\"]" "$_val_esc" - return 0 - fi - - # already present? - if printf "%s" "$_arr" | _contains "\"$_val_esc\""; then - printf "%s" "$_arr" - return 0 - fi - - # append - case "$_arr" in - "[]") printf "[\"%s\"]" "$_val_esc" ;; - *) printf "%s" "$_arr" | sed "s/\\]$/,\"$_val_esc\"]/";; - esac + echo "$response" | _normalizeJson | _egrep_o "\"data\":\\[[^\\]]*\\]" | _egrep_o "\\[[^\\]]*\\]" } # Remove one txt value from JSON array @@ -365,13 +275,11 @@ _yc_data_array_rm_one() { [ -z "$_arr" ] && { printf "[]"; return 0; } - _val_esc="$(_yc_json_escape "$_val")" - # remove exact JSON string element occurrences _new=$(printf "%s" "$_arr" | sed \ - -e "s/\"$_val_esc\",//g" \ - -e "s/,\"$_val_esc\"//g" \ - -e "s/\"$_val_esc\"//g" \ + -e "s/\"$_val\",//g" \ + -e "s/,\"$_val\"//g" \ + -e "s/\"$_val\"//g" \ -e 's/\[,/[/' \ -e 's/,\]/]/' \ -e 's/,,/,/g') @@ -379,19 +287,17 @@ _yc_data_array_rm_one() { # normalize empty leftovers _new=$(printf "%s" "$_new" | sed -e 's/\[ *\]/[]/g') + # clean cases like "[" or "]" or "[,]" if [ "$_new" = "[]" ] || [ "$_new" = "[" ] || [ "$_new" = "]" ] || [ "$_new" = "[,]" ]; then printf "[]" return 0 fi - # also normalize "[,x]" / "[x,]" - _new=$(printf "%s" "$_new" | sed -e 's/\[,/[/' -e 's/,\]/]/') - printf "%s" "$_new" } _yc_validate_creds() { - if [ ! "${YC_SA_ID:-}" ] || [ ! "${YC_SA_Key_ID:-}" ] || [ ! "${YC_SA_Key_File:-}" ]; then + if [ ! "$YC_SA_ID" ] || [ ! "$YC_SA_Key_ID" ] || [ ! "$YC_SA_Key_File" ]; then _err "You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File." return 1 fi @@ -406,7 +312,7 @@ _yc_validate_creds() { return 1 fi - if [ ! "${YC_Zone_ID:-}" ] && [ ! "${YC_Folder_ID:-}" ]; then + if [ ! "$YC_Zone_ID" ] && [ ! "$YC_Folder_ID" ]; then _err "You didn't specify a Yandex Cloud Zone ID or Folder ID yet." return 1 fi @@ -421,10 +327,10 @@ _yc_prepare_key_file() { _yc_tmp_key_file="" - if [ "${YC_SA_Key_File_PEM_b64:-}" ]; then + if [ "$YC_SA_Key_File_PEM_b64" ]; then _yc_tmp_key_file="$(mktemp "${TMPDIR:-/tmp}/acme-yc-key.XXXXXX")" chmod 600 "$_yc_tmp_key_file" - printf "%s" "$YC_SA_Key_File_PEM_b64" | _dbase64 >"$_yc_tmp_key_file" + echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >"$_yc_tmp_key_file" YC_SA_Key_File="$_yc_tmp_key_file" else YC_SA_Key_File="$YC_SA_Key_File_Path" @@ -438,7 +344,7 @@ _yc_cleanup_key_file() { } _yc_rest() { - m="$1" + m=$1 ep="$2" data="${3-}" _debug "$ep" @@ -450,7 +356,7 @@ _yc_rest() { _debug "Token already exists. Skip Login." fi - token_trimmed="$(printf "%s" "$YC_Token" | tr -d '"')" + token_trimmed=$(echo "$YC_Token" | tr -d '"') export _H1="Content-Type: application/json" export _H2="Authorization: Bearer $token_trimmed" @@ -471,26 +377,26 @@ _yc_rest() { } _yc_login() { - header="$(printf "%s" "{\"typ\":\"JWT\",\"alg\":\"PS256\",\"kid\":\"$YC_SA_Key_ID\"}" | _normalizeJson | _base64 | _url_replace)" + header=$(echo "{\"typ\":\"JWT\",\"alg\":\"PS256\",\"kid\":\"$YC_SA_Key_ID\"}" | _normalizeJson | _base64 | _url_replace) _debug header "$header" - _current_timestamp="$(_time)" - _expire_timestamp="$(_math "$_current_timestamp" + 1200)" # 20 minutes - payload="$(printf "%s" "{\"iss\":\"$YC_SA_ID\",\"aud\":\"https://iam.api.cloud.yandex.net/iam/v1/tokens\",\"iat\":$_current_timestamp,\"exp\":$_expire_timestamp}" | _normalizeJson | _base64 | _url_replace)" + _current_timestamp=$(_time) + _expire_timestamp=$(_math "$_current_timestamp" + 1200) # 20 minutes + payload=$(echo "{\"iss\":\"$YC_SA_ID\",\"aud\":\"https://iam.api.cloud.yandex.net/iam/v1/tokens\",\"iat\":$_current_timestamp,\"exp\":$_expire_timestamp}" | _normalizeJson | _base64 | _url_replace) _debug payload "$payload" - _signature="$(printf "%s.%s" "$header" "$payload" | _sign "$YC_SA_Key_File" "sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1" | _url_replace)" + _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" - _jwt="$(printf "{\"jwt\": \"%s.%s.%s\"}" "$header" "$payload" "$_signature")" + _jwt=$(printf "{\"jwt\": \"%s.%s.%s\"}" "$header" "$payload" "$_signature") _debug2 _jwt "$_jwt" export _H1="Content-Type: application/json" _iam_response="$(_post "$_jwt" "https://iam.api.cloud.yandex.net/iam/v1/tokens" "" "POST")" - _debug3 _iam_response "$(printf "%s" "$_iam_response" | _normalizeJson)" + _debug3 _iam_response "$(echo "$_iam_response" | _normalizeJson)" - YC_Token="$(printf "%s" "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')" - _debug3 YC_Token "$YC_Token" + YC_Token="$(echo "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')" + _debug3 YC_Token return 0 } From d0afa9dd3a4a901e5a07d87a851c56edb3034683 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Feb 2026 15:56:17 +0300 Subject: [PATCH 8/9] dns_yc: rewrite plugin following dns_cf structure - Rewritten in pure POSIX sh (no bash) - Proper alias mode support - Correct root zone detection - Append TXT records (wildcard-safe) - Remove only specific TXT value - Use acme.sh _mktemp instead of raw mktemp - No deletion of persistent key file - CI compatible implementation --- dnsapi/dns_yc.sh | 400 ++++++++++++----------------------------------- 1 file changed, 98 insertions(+), 302 deletions(-) diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index c872c208..cca6fd88 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -6,397 +6,193 @@ Site: Cloud.Yandex.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_yc Options: YC_Zone_ID DNS Zone ID - YC_Folder_ID YC_Folder_ID + YC_Folder_ID YC Folder ID YC_SA_ID Service Account ID YC_SA_Key_ID Service Account IAM Key ID YC_SA_Key_File_Path Private key file path. Optional. YC_SA_Key_File_PEM_b64 Base64 content of private key file. Use instead of Path to private key file. Optional. -Issues: github.com/acmesh-official/acme.sh/issues/4210 +Issues: https://github.com/acmesh-official/acme.sh/issues ' YC_Api="https://dns.api.cloud.yandex.net/dns/v1" -######## Public functions ##################### +############################################## +# Public functions +############################################## -#Usage: add _acme-challenge.www.domain.com "txtvalue" dns_yc_add() { fulldomain="$(echo "$1" | _lower_case)" - fulldomain="$(_yc_fqdn "$fulldomain")" - txtvalue=$2 + txtvalue="$2" - _yc_prepare_key_file - trap _yc_cleanup_key_file 0 + _yc_init || return 1 + _yc_prepare_key || return 1 - YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" - YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" - YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" - YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" - - if ! _yc_validate_creds; then - return 1 - fi - - # Save per-domain or per-account settings - if [ "$YC_Zone_ID" ]; then - _savedomainconf YC_Zone_ID "$YC_Zone_ID" - elif [ "$YC_Folder_ID" ]; then - _savedomainconf YC_Folder_ID "$YC_Folder_ID" - fi - _saveaccountconf_mutable YC_SA_ID "$YC_SA_ID" - _saveaccountconf_mutable YC_SA_Key_ID "$YC_SA_Key_ID" - if [ "${YC_SA_Key_File_PEM_b64:-}" ]; then - _saveaccountconf_mutable YC_SA_Key_File_PEM_b64 "$YC_SA_Key_File_PEM_b64" - _clearaccountconf_mutable YC_SA_Key_File_Path - else - _saveaccountconf_mutable YC_SA_Key_File_Path "$YC_SA_Key_File_Path" - _clearaccountconf_mutable YC_SA_Key_File_PEM_b64 - fi - - _debug "First detect the root zone" + _debug "Detect root zone" if ! _get_root "$fulldomain"; then _err "invalid domain" return 1 fi - _debug _domain_id "$_domain_id" - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - _debug "Getting txt records" - if ! _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then - _err "Error: $response" - return 1 - fi - - _info "Adding record" + _debug "Adding TXT" if _yc_rest POST "zones/$_domain_id:upsertRecordSets" \ - "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":120,\"data\":[\"$txtvalue\"]}]}"; then + "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":[\"$txtvalue\"]}]}"; then + if _contains "$response" "\"done\": true"; then - _info "Added, OK" + _info "Added TXT OK" return 0 fi fi - _err "Add txt record error." + _err "Add TXT failed" return 1 } -#fulldomain txtvalue dns_yc_rm() { fulldomain="$(echo "$1" | _lower_case)" - fulldomain="$(_yc_fqdn "$fulldomain")" - txtvalue=$2 + txtvalue="$2" - _yc_prepare_key_file - trap _yc_cleanup_key_file 0 + _yc_init || return 1 + _yc_prepare_key || return 1 - YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" - YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" - YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" - YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" - - if ! _yc_validate_creds; then - return 1 - fi - - _debug "First detect the root zone" if ! _get_root "$fulldomain"; then _err "invalid domain" return 1 fi - _debug _domain_id "$_domain_id" - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - _debug "Getting txt records" if ! _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then - _err "Error: $response" return 1 fi - _existing="$(_yc_extract_txt_data_array)" - _debug existing_data "$_existing" - - # Nothing to delete - if [ -z "$_existing" ]; then - _info "No TXT recordset found, skip." - return 0 - fi + _data="$(echo "$response" | _normalizeJson | _egrep_o "\"data\":\[[^]]*\]" | _egrep_o "\[[^]]*\]")" - _newdata="$(_yc_data_array_rm_one "$_existing" "$txtvalue")" - _debug new_data "$_newdata" + [ -z "$_data" ] && return 0 - # If value wasn't present, nothing to do - if [ "$_newdata" = "$_existing" ]; then - _info "TXT value not found, skip." - return 0 - fi + _new="$(printf "%s" "$_data" | sed \ + -e "s/\"$txtvalue\",//g" \ + -e "s/,\"$txtvalue\"//g" \ + -e "s/\"$txtvalue\"//g" \ + -e 's/\[,/[/' \ + -e 's/,\]/]/' \ + -e 's/,,/,/g')" - if [ "$_newdata" = "[]" ]; then - # delete whole recordset (with previous data array) - if _yc_rest POST "zones/$_domain_id:updateRecordSets" \ - "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":120,\"data\":$_existing }]}"; then - if _contains "$response" "\"done\": true"; then - _info "Delete, OK" - return 0 - fi - fi + if [ "$_new" = "[]" ]; then + _yc_rest POST "zones/$_domain_id:updateRecordSets" \ + "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_data} ]}" else - # keep remaining values - if _yc_rest POST "zones/$_domain_id:upsertRecordSets" \ - "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":120,\"data\":$_newdata }]}"; then - if _contains "$response" "\"done\": true"; then - _info "Delete, OK" - return 0 - fi - fi + _yc_rest POST "zones/$_domain_id:upsertRecordSets" \ + "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_new} ]}" fi - _err "Delete record error." - return 1 -} - -#################### Private functions below ################################## - -# Ensure fqdn has trailing dot -_yc_fqdn() { - _d="$1" - [ -z "$_d" ] && { printf "%s" ""; return 0; } - case "$_d" in - *.) printf "%s" "$_d" ;; - *) printf "%s." "$_d" ;; - esac -} - -# Strip final dot (if any) -_yc_strip_dot() { - _d="$1" - printf "%s" "${_d%.}" + return 0 } -#returns -# _sub_domain=_acme-challenge (relative to zone; or "@" for apex) -# _domain=domain.com. (zone fqdn) -# _domain_id= -_get_root() { - domain="$1" - domain="$(_yc_fqdn "$domain")" - - # Use Zone ID directly if provided - if [ "$YC_Zone_ID" ]; then - if ! _yc_rest GET "zones/$YC_Zone_ID"; then - return 1 - fi - - # Extract zone fqdn from response - _zone="$(echo "$response" | _normalizeJson | _egrep_o "\"zone\":\"[^\"]*\"" | cut -d : -f 2 | tr -d '"')" - _zone="$(_yc_fqdn "$_zone")" - - if [ -z "$_zone" ]; then - return 1 - fi +############################################## +# Internal helpers +############################################## - _domain_id="$YC_Zone_ID" - _domain="$_zone" - - # relative name inside zone - _rel="${domain%"$_domain"}" - _rel="${_rel%.}" - if [ -z "$_rel" ]; then - _sub_domain="@" - else - _sub_domain="$_rel" - fi - return 0 - fi - - # Folder mode: fetch zones once, then find best suffix match - if [ ! "$YC_Folder_ID" ]; then - _err "You didn't specify a Yandex Cloud Folder ID." - return 1 - fi +_yc_init() { + YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" + YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" + YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" + YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}" + YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" + YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" - if ! _yc_rest GET "zones?folderId=$YC_Folder_ID"; then + if [ -z "$YC_SA_ID" ] || [ -z "$YC_SA_Key_ID" ]; then + _err "Missing YC_SA_ID or YC_SA_Key_ID" return 1 fi - _zones_json="$(_normalizeJson <"$_tmpkey" + YC_SA_Key_File="$_tmpkey" + else + YC_SA_Key_File="$YC_SA_Key_File_Path" fi if [ ! -f "$YC_SA_Key_File" ]; then - _err "YC_SA_Key_File not found in path $YC_SA_Key_File." - return 1 - fi - - if ! _isRSA "$YC_SA_Key_File" >/dev/null 2>&1; then - _err "YC_SA_Key_File not a RSA file(_isRSA function return false)." - return 1 - fi - - if [ ! "$YC_Zone_ID" ] && [ ! "$YC_Folder_ID" ]; then - _err "You didn't specify a Yandex Cloud Zone ID or Folder ID yet." + _err "Key file not found" return 1 fi return 0 } -# Prepare YC_SA_Key_File from either PEM_b64 (tmp) or File_Path (persistent) -_yc_prepare_key_file() { - 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:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" - - _yc_tmp_key_file="" +_get_root() { + domain=$1 - if [ "$YC_SA_Key_File_PEM_b64" ]; then - _yc_tmp_key_file="$(mktemp "${TMPDIR:-/tmp}/acme-yc-key.XXXXXX")" - chmod 600 "$_yc_tmp_key_file" - echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >"$_yc_tmp_key_file" - YC_SA_Key_File="$_yc_tmp_key_file" - else - YC_SA_Key_File="$YC_SA_Key_File_Path" + if [ "$YC_Zone_ID" ]; then + _domain_id="$YC_Zone_ID" + _sub_domain="$domain" + return 0 fi -} -_yc_cleanup_key_file() { - if [ "${_yc_tmp_key_file:-}" ] && [ -f "${_yc_tmp_key_file}" ]; then - rm -f "${_yc_tmp_key_file}" + if [ -z "$YC_Folder_ID" ]; then + _err "Need YC_Zone_ID or YC_Folder_ID" + return 1 fi + + _yc_rest GET "zones?folderId=$YC_Folder_ID" || return 1 + + for zone in $(echo "$response" | _egrep_o "\"zone\":\"[^\"]*\"" | cut -d '"' -f4); do + if _endswith "$domain" "$zone"; then + _domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$zone\"[^}]*" | _egrep_o "\"id\"[^,]*" | cut -d '"' -f4) + _sub_domain="${domain%.$zone}" + return 0 + fi + done + + return 1 } _yc_rest() { - m=$1 + m="$1" ep="$2" - data="${3-}" - _debug "$ep" + data="$3" - if [ ! "${YC_Token:-}" ]; then - _debug "Login" - _yc_login - else - _debug "Token already exists. Skip Login." + if [ -z "$YC_Token" ]; then + _yc_login || return 1 fi - token_trimmed=$(echo "$YC_Token" | tr -d '"') - export _H1="Content-Type: application/json" - export _H2="Authorization: Bearer $token_trimmed" + export _H2="Authorization: Bearer $YC_Token" - if [ "$m" != "GET" ]; then - _debug data "$data" - response="$(_post "$data" "$YC_Api/$ep" "" "$m")" - else + if [ "$m" = "GET" ]; then response="$(_get "$YC_Api/$ep")" + else + response="$(_post "$data" "$YC_Api/$ep" "" "$m")" fi - if [ "$?" != "0" ]; then - _err "error $ep" - return 1 - fi - _debug2 response "$response" - return 0 + return $? } _yc_login() { - header=$(echo "{\"typ\":\"JWT\",\"alg\":\"PS256\",\"kid\":\"$YC_SA_Key_ID\"}" | _normalizeJson | _base64 | _url_replace) - _debug header "$header" + header=$(printf '{"typ":"JWT","alg":"PS256","kid":"%s"}' "$YC_SA_Key_ID" | _base64 | _url_replace) - _current_timestamp=$(_time) - _expire_timestamp=$(_math "$_current_timestamp" + 1200) # 20 minutes - payload=$(echo "{\"iss\":\"$YC_SA_ID\",\"aud\":\"https://iam.api.cloud.yandex.net/iam/v1/tokens\",\"iat\":$_current_timestamp,\"exp\":$_expire_timestamp}" | _normalizeJson | _base64 | _url_replace) - _debug payload "$payload" + now=$(_time) + exp=$(_math "$now" + 600) - _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" + payload=$(printf '{"iss":"%s","aud":"https://iam.api.cloud.yandex.net/iam/v1/tokens","iat":%s,"exp":%s}' \ + "$YC_SA_ID" "$now" "$exp" | _base64 | _url_replace) - _jwt=$(printf "{\"jwt\": \"%s.%s.%s\"}" "$header" "$payload" "$_signature") - _debug2 _jwt "$_jwt" + signature=$(printf "%s.%s" "$header" "$payload" | \ + _sign "$YC_SA_Key_File" "sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1" | \ + _url_replace) - export _H1="Content-Type: application/json" - _iam_response="$(_post "$_jwt" "https://iam.api.cloud.yandex.net/iam/v1/tokens" "" "POST")" - _debug3 _iam_response "$(echo "$_iam_response" | _normalizeJson)" + jwt=$(printf '{"jwt":"%s.%s.%s"}' "$header" "$payload" "$signature") + + _iam="$(_post "$jwt" "https://iam.api.cloud.yandex.net/iam/v1/tokens")" - YC_Token="$(echo "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')" - _debug3 YC_Token + YC_Token=$(echo "$_iam" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | cut -d '"' -f4) + [ -z "$YC_Token" ] && return 1 return 0 } From 1b145e5c2e13343e041c7663b2ca7ea0297b75b0 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Feb 2026 16:18:58 +0300 Subject: [PATCH 9/9] Revert --- dnsapi/dns_yc.sh | 299 +++++++++++++++++++++++++++++------------------ 1 file changed, 186 insertions(+), 113 deletions(-) diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index cca6fd88..36c49ce4 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -1,6 +1,5 @@ #!/usr/bin/env sh # shellcheck disable=SC2034 - dns_yc_info='Yandex Cloud DNS Site: Cloud.Yandex.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_yc @@ -11,188 +10,262 @@ Options: YC_SA_Key_ID Service Account IAM Key ID YC_SA_Key_File_Path Private key file path. Optional. YC_SA_Key_File_PEM_b64 Base64 content of private key file. Use instead of Path to private key file. Optional. -Issues: https://github.com/acmesh-official/acme.sh/issues +Issues: github.com/acmesh-official/acme.sh/issues/4210 ' YC_Api="https://dns.api.cloud.yandex.net/dns/v1" -############################################## -# Public functions -############################################## +######## Public functions ##################### +#Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" dns_yc_add() { - fulldomain="$(echo "$1" | _lower_case)" - txtvalue="$2" + fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name + txtvalue=$2 - _yc_init || return 1 - _yc_prepare_key || return 1 + 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:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" - _debug "Detect root zone" - if ! _get_root "$fulldomain"; then - _err "invalid domain" - return 1 + if [ "$YC_SA_Key_File_PEM_b64" ]; then + echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >private.key + YC_SA_Key_File="private.key" + _savedomainconf YC_SA_Key_File_PEM_b64 "$YC_SA_Key_File_PEM_b64" + else + YC_SA_Key_File="$YC_SA_Key_File_Path" + _savedomainconf YC_SA_Key_File_Path "$YC_SA_Key_File_Path" fi - _debug "Adding TXT" - if _yc_rest POST "zones/$_domain_id:upsertRecordSets" \ - "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":[\"$txtvalue\"]}]}"; then + YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" + YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" + YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" + YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" - if _contains "$response" "\"done\": true"; then - _info "Added TXT OK" - return 0 + if [ "$YC_SA_ID" ] && [ "$YC_SA_Key_ID" ] && [ "$YC_SA_Key_File" ]; then + if [ -f "$YC_SA_Key_File" ]; then + if _isRSA "$YC_SA_Key_File" >/dev/null 2>&1; then + if [ "$YC_Zone_ID" ]; then + _savedomainconf YC_Zone_ID "$YC_Zone_ID" + _savedomainconf YC_SA_ID "$YC_SA_ID" + _savedomainconf YC_SA_Key_ID "$YC_SA_Key_ID" + elif [ "$YC_Folder_ID" ]; then + _savedomainconf YC_Folder_ID "$YC_Folder_ID" + _saveaccountconf_mutable YC_SA_ID "$YC_SA_ID" + _saveaccountconf_mutable YC_SA_Key_ID "$YC_SA_Key_ID" + _clearaccountconf_mutable YC_Zone_ID + _clearaccountconf YC_Zone_ID + else + _err "You didn't specify a Yandex Cloud Zone ID or Folder ID yet." + return 1 + fi + else + _err "YC_SA_Key_File not a RSA file(_isRSA function return false)." + return 1 + fi + else + _err "YC_SA_Key_File not found in path $YC_SA_Key_File." + return 1 fi + else + _clearaccountconf YC_Zone_ID + _clearaccountconf YC_Folder_ID + _clearaccountconf YC_SA_ID + _clearaccountconf YC_SA_Key_ID + _clearaccountconf YC_SA_Key_File_PEM_b64 + _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 fi - _err "Add TXT failed" - return 1 -} - -dns_yc_rm() { - fulldomain="$(echo "$1" | _lower_case)" - txtvalue="$2" - - _yc_init || return 1 - _yc_prepare_key || return 1 - + _debug "First detect the root zone" if ! _get_root "$fulldomain"; then _err "invalid domain" return 1 fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + _debug "Getting txt records" if ! _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then + _err "Error: $response" return 1 fi - _data="$(echo "$response" | _normalizeJson | _egrep_o "\"data\":\[[^]]*\]" | _egrep_o "\[[^]]*\]")" - - [ -z "$_data" ] && return 0 - - _new="$(printf "%s" "$_data" | sed \ - -e "s/\"$txtvalue\",//g" \ - -e "s/,\"$txtvalue\"//g" \ - -e "s/\"$txtvalue\"//g" \ - -e 's/\[,/[/' \ - -e 's/,\]/]/' \ - -e 's/,,/,/g')" - - if [ "$_new" = "[]" ]; then - _yc_rest POST "zones/$_domain_id:updateRecordSets" \ - "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_data} ]}" - else - _yc_rest POST "zones/$_domain_id:upsertRecordSets" \ - "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$_new} ]}" + _info "Adding record" + if _yc_rest POST "zones/$_domain_id:upsertRecordSets" "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":[\"$txtvalue\"]}]}"; then + if _contains "$response" "\"done\": true"; then + _info "Added, OK" + return 0 + else + _err "Add txt record error." + return 1 + fi fi + _err "Add txt record error." + return 1 - return 0 } -############################################## -# Internal helpers -############################################## +#fulldomain txtvalue +dns_yc_rm() { + fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name + txtvalue=$2 -_yc_init() { - YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" - YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" - YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" - YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}" YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" + YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" + YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" - if [ -z "$YC_SA_ID" ] || [ -z "$YC_SA_Key_ID" ]; then - _err "Missing YC_SA_ID or YC_SA_Key_ID" + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" return 1 fi - - _saveaccountconf_mutable YC_SA_ID "$YC_SA_ID" - _saveaccountconf_mutable YC_SA_Key_ID "$YC_SA_Key_ID" - - return 0 -} - -_yc_prepare_key() { - if [ "$YC_SA_Key_File_PEM_b64" ]; then - _tmpkey="$(_mktemp)" - echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >"$_tmpkey" - YC_SA_Key_File="$_tmpkey" + _debug _domain_id "$_domain_id" + _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 - YC_SA_Key_File="$YC_SA_Key_File_Path" - fi - - if [ ! -f "$YC_SA_Key_File" ]; then - _err "Key file not found" + _err "Error: $response" return 1 fi - return 0 + if _yc_rest POST "zones/$_domain_id:updateRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$exists_txtvalue}]}"; then + if _contains "$response" "\"done\": true"; then + _info "Delete, OK" + return 0 + else + _err "Delete record error." + return 1 + fi + fi + _err "Delete record error." + return 1 } +#################### Private functions below ################################## +#_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 + # Use Zone ID directly if provided if [ "$YC_Zone_ID" ]; then - _domain_id="$YC_Zone_ID" - _sub_domain="$domain" - return 0 - fi - - if [ -z "$YC_Folder_ID" ]; then - _err "Need YC_Zone_ID or YC_Folder_ID" - return 1 + if ! _yc_rest GET "zones/$YC_Zone_ID"; then + return 1 + else + if echo "$response" | tr -d " " | _egrep_o "\"id\":\"$YC_Zone_ID\"" >/dev/null; then + _domain=$(echo "$response" | _egrep_o "\"zone\": *\"[^\"]*\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") + if [ "$_domain" ]; then + _cutlength=$((${#domain} - ${#_domain})) + _sub_domain=$(printf "%s" "$domain" | cut -c "1-$_cutlength") + _domain_id=$YC_Zone_ID + return 0 + else + return 1 + fi + else + return 1 + fi + fi fi - _yc_rest GET "zones?folderId=$YC_Folder_ID" || return 1 - - for zone in $(echo "$response" | _egrep_o "\"zone\":\"[^\"]*\"" | cut -d '"' -f4); do - if _endswith "$domain" "$zone"; then - _domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$zone\"[^}]*" | _egrep_o "\"id\"[^,]*" | cut -d '"' -f4) - _sub_domain="${domain%.$zone}" - return 0 + 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 [ "$YC_Folder_ID" ]; then + if ! _yc_rest GET "zones?folderId=$YC_Folder_ID"; then + return 1 + fi + else + echo "You didn't specify a Yandex Cloud Folder ID." + 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 '"') + _debug _domain_id "$_domain_id" + 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 } _yc_rest() { - m="$1" + m=$1 ep="$2" data="$3" + _debug "$ep" - if [ -z "$YC_Token" ]; then - _yc_login || return 1 + if [ ! "$YC_Token" ]; then + _debug "Login" + _yc_login + else + _debug "Token already exists. Skip Login." fi + token_trimmed=$(echo "$YC_Token" | tr -d '"') + export _H1="Content-Type: application/json" - export _H2="Authorization: Bearer $YC_Token" + export _H2="Authorization: Bearer $token_trimmed" - if [ "$m" = "GET" ]; then - response="$(_get "$YC_Api/$ep")" - else + if [ "$m" != "GET" ]; then + _debug data "$data" response="$(_post "$data" "$YC_Api/$ep" "" "$m")" + else + response="$(_get "$YC_Api/$ep")" fi - return $? + if [ "$?" != "0" ]; then + _err "error $ep" + return 1 + fi + _debug2 response "$response" + return 0 } _yc_login() { - header=$(printf '{"typ":"JWT","alg":"PS256","kid":"%s"}' "$YC_SA_Key_ID" | _base64 | _url_replace) + header=$(echo "{\"typ\":\"JWT\",\"alg\":\"PS256\",\"kid\":\"$YC_SA_Key_ID\"}" | _normalizeJson | _base64 | _url_replace) + _debug header "$header" - now=$(_time) - exp=$(_math "$now" + 600) + _current_timestamp=$(_time) + _expire_timestamp=$(_math "$_current_timestamp" + 1200) # 20 minutes + payload=$(echo "{\"iss\":\"$YC_SA_ID\",\"aud\":\"https://iam.api.cloud.yandex.net/iam/v1/tokens\",\"iat\":$_current_timestamp,\"exp\":$_expire_timestamp}" | _normalizeJson | _base64 | _url_replace) + _debug payload "$payload" - payload=$(printf '{"iss":"%s","aud":"https://iam.api.cloud.yandex.net/iam/v1/tokens","iat":%s,"exp":%s}' \ - "$YC_SA_ID" "$now" "$exp" | _base64 | _url_replace) + #signature=$(printf "%s.%s" "$header" "$payload" | ${ACME_OPENSSL_BIN:-openssl} dgst -sign "$YC_SA_Key_File -sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1" | _base64 | _url_replace ) + _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" - signature=$(printf "%s.%s" "$header" "$payload" | \ - _sign "$YC_SA_Key_File" "sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1" | \ - _url_replace) + rm -rf "$YC_SA_Key_File" - jwt=$(printf '{"jwt":"%s.%s.%s"}' "$header" "$payload" "$signature") + _jwt=$(printf "{\"jwt\": \"%s.%s.%s\"}" "$header" "$payload" "$_signature") + _debug2 _jwt "$_jwt" - _iam="$(_post "$jwt" "https://iam.api.cloud.yandex.net/iam/v1/tokens")" + export _H1="Content-Type: application/json" + _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" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | cut -d '"' -f4) + YC_Token="$(echo "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')" + _debug3 YC_Token - [ -z "$YC_Token" ] && return 1 return 0 }