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 }