From d0afa9dd3a4a901e5a07d87a851c56edb3034683 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Feb 2026 15:56:17 +0300 Subject: [PATCH] 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 }