From 7470b0babae6f44f3f210612d19125343d3c5af9 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Feb 2026 13:24:32 +0300 Subject: [PATCH] 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 +}