#!/usr/bin/env sh # shellcheck disable=SC2034 dns_hetznercloud_info='Hetzner Cloud DNS Site: Hetzner.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_hetznercloud Options: HETZNER_TOKEN API token for the Hetzner Cloud DNS API Optional: HETZNER_TTL Custom TTL for new TXT rrsets (default 120) HETZNER_API Override API endpoint (default https://api.hetzner.cloud/v1) Issues: github.com/acmesh-official/acme.sh/issues ' HETZNERCLOUD_API_DEFAULT="https://api.hetzner.cloud/v1" HETZNERCLOUD_TTL_DEFAULT=120 ######## Public functions ##################### dns_hetznercloud_add() { fulldomain="$(_idn "${1}")" txtvalue="${2}" _info "Using Hetzner Cloud DNS API to add record" if ! _hetznercloud_init; then return 1 fi if ! _hetznercloud_prepare_zone "${fulldomain}"; then _err "Unable to determine Hetzner Cloud zone for ${fulldomain}" return 1 fi if ! _hetznercloud_get_rrset; then return 1 fi if [ "${_hetznercloud_last_http_code}" = "200" ]; then if _hetznercloud_rrset_contains_value "${txtvalue}"; then _info "TXT record already present; nothing to do." return 0 fi elif [ "${_hetznercloud_last_http_code}" != "404" ]; then _hetznercloud_log_http_error "Failed to query existing TXT rrset" "${_hetznercloud_last_http_code}" return 1 fi add_payload="$(_hetznercloud_build_add_payload "${txtvalue}")" if [ -z "${add_payload}" ]; then _err "Failed to build request payload." return 1 fi if ! _hetznercloud_api POST "${_hetznercloud_rrset_action_add}" "${add_payload}"; then return 1 fi case "${_hetznercloud_last_http_code}" in 200 | 201 | 202 | 204) _info "Hetzner Cloud TXT record added." return 0 ;; 401 | 403) _err "Hetzner Cloud DNS API authentication failed (HTTP ${_hetznercloud_last_http_code}). Check HETZNER_TOKEN for the new API." _hetznercloud_log_http_error "" "${_hetznercloud_last_http_code}" return 1 ;; 409 | 422) _hetznercloud_log_http_error "Hetzner Cloud DNS rejected the add_records request" "${_hetznercloud_last_http_code}" return 1 ;; *) _hetznercloud_log_http_error "Hetzner Cloud DNS add_records request failed" "${_hetznercloud_last_http_code}" return 1 ;; esac } dns_hetznercloud_rm() { fulldomain="$(_idn "${1}")" txtvalue="${2}" _info "Using Hetzner Cloud DNS API to remove record" if ! _hetznercloud_init; then return 1 fi if ! _hetznercloud_prepare_zone "${fulldomain}"; then _err "Unable to determine Hetzner Cloud zone for ${fulldomain}" return 1 fi if ! _hetznercloud_get_rrset; then return 1 fi if [ "${_hetznercloud_last_http_code}" = "404" ]; then _info "TXT rrset does not exist; nothing to remove." return 0 fi if [ "${_hetznercloud_last_http_code}" != "200" ]; then _hetznercloud_log_http_error "Failed to query existing TXT rrset" "${_hetznercloud_last_http_code}" return 1 fi if _hetznercloud_rrset_contains_value "${txtvalue}"; then remove_payload="$(_hetznercloud_build_remove_payload "${txtvalue}")" if [ -z "${remove_payload}" ]; then _err "Failed to build remove_records payload." return 1 fi if ! _hetznercloud_api POST "${_hetznercloud_rrset_action_remove}" "${remove_payload}"; then return 1 fi case "${_hetznercloud_last_http_code}" in 200 | 201 | 202 | 204) _info "Hetzner Cloud TXT record removed." return 0 ;; 401 | 403) _err "Hetzner Cloud DNS API authentication failed (HTTP ${_hetznercloud_last_http_code}). Check HETZNER_TOKEN for the new API." _hetznercloud_log_http_error "" "${_hetznercloud_last_http_code}" return 1 ;; 404) _info "TXT rrset already absent after remove action." return 0 ;; 409 | 422) _hetznercloud_log_http_error "Hetzner Cloud DNS rejected the remove_records request" "${_hetznercloud_last_http_code}" return 1 ;; *) _hetznercloud_log_http_error "Hetzner Cloud DNS remove_records request failed" "${_hetznercloud_last_http_code}" return 1 ;; esac else _info "TXT value not present; nothing to remove." return 0 fi } #################### Private functions ################################## _hetznercloud_init() { HETZNER_TOKEN="${HETZNER_TOKEN:-$(_readaccountconf_mutable HETZNER_TOKEN)}" if [ -z "${HETZNER_TOKEN}" ]; then _err "The environment variable HETZNER_TOKEN must be set for the Hetzner Cloud DNS API." return 1 fi HETZNER_TOKEN=$(echo "${HETZNER_TOKEN}" | tr -d '"') _saveaccountconf_mutable HETZNER_TOKEN "${HETZNER_TOKEN}" HETZNER_API="${HETZNER_API:-$(_readaccountconf_mutable HETZNER_API)}" if [ -z "${HETZNER_API}" ]; then HETZNER_API="${HETZNERCLOUD_API_DEFAULT}" fi _saveaccountconf_mutable HETZNER_API "${HETZNER_API}" HETZNER_TTL="${HETZNER_TTL:-$(_readaccountconf_mutable HETZNER_TTL)}" if [ -z "${HETZNER_TTL}" ]; then HETZNER_TTL="${HETZNERCLOUD_TTL_DEFAULT}" fi ttl_check=$(printf "%s" "${HETZNER_TTL}" | tr -d '0-9') if [ -n "${ttl_check}" ]; then _err "HETZNER_TTL must be an integer value." return 1 fi _saveaccountconf_mutable HETZNER_TTL "${HETZNER_TTL}" return 0 } _hetznercloud_prepare_zone() { _hetznercloud_zone_id="" _hetznercloud_zone_name="" _hetznercloud_zone_name_lc="" _hetznercloud_rr_name="" _hetznercloud_rrset_path="" _hetznercloud_rrset_action_add="" _hetznercloud_rrset_action_remove="" fulldomain_lc=$(printf "%s" "${1}" | sed 's/\.$//' | _lower_case) i=2 p=1 while true; do candidate=$(printf "%s" "${fulldomain_lc}" | cut -d . -f "${i}"-100) if [ -z "${candidate}" ]; then return 1 fi if _hetznercloud_get_zone_by_candidate "${candidate}"; then zone_name_lc="${_hetznercloud_zone_name_lc}" if [ "${fulldomain_lc}" = "${zone_name_lc}" ]; then _hetznercloud_rr_name="@" else suffix=".${zone_name_lc}" if _endswith "${fulldomain_lc}" "${suffix}"; then _hetznercloud_rr_name="${fulldomain_lc%"${suffix}"}" else _hetznercloud_rr_name="${fulldomain_lc}" fi fi _hetznercloud_rrset_path=$(printf "%s" "${_hetznercloud_rr_name}" | _url_encode) _hetznercloud_rrset_action_add="/zones/${_hetznercloud_zone_id}/rrsets/${_hetznercloud_rrset_path}/TXT/actions/add_records" _hetznercloud_rrset_action_remove="/zones/${_hetznercloud_zone_id}/rrsets/${_hetznercloud_rrset_path}/TXT/actions/remove_records" return 0 fi p=${i} i=$(_math "${i}" + 1) done } _hetznercloud_get_zone_by_candidate() { candidate="${1}" zone_key=$(printf "%s" "${candidate}" | sed 's/[^A-Za-z0-9]/_/g') zone_conf_key="HETZNERCLOUD_ZONE_ID_for_${zone_key}" cached_zone_id=$(_readdomainconf "${zone_conf_key}") if [ -n "${cached_zone_id}" ]; then if _hetznercloud_api GET "/zones/${cached_zone_id}"; then if [ "${_hetznercloud_last_http_code}" = "200" ]; then zone_data=$(printf "%s" "${response}" | _normalizeJson | sed 's/^{"zone"://' | sed 's/}$//') if _hetznercloud_parse_zone_fields "${zone_data}"; then zone_name_lc=$(printf "%s" "${_hetznercloud_zone_name}" | _lower_case) if [ "${zone_name_lc}" = "${candidate}" ]; then return 0 fi fi elif [ "${_hetznercloud_last_http_code}" = "404" ]; then _cleardomainconf "${zone_conf_key}" fi else return 1 fi fi if _hetznercloud_api GET "/zones/${candidate}"; then if [ "${_hetznercloud_last_http_code}" = "200" ]; then zone_data=$(printf "%s" "${response}" | _normalizeJson | sed 's/^{"zone"://' | sed 's/}$//') if _hetznercloud_parse_zone_fields "${zone_data}"; then zone_name_lc=$(printf "%s" "${_hetznercloud_zone_name}" | _lower_case) if [ "${zone_name_lc}" = "${candidate}" ]; then _savedomainconf "${zone_conf_key}" "${_hetznercloud_zone_id}" return 0 fi fi elif [ "${_hetznercloud_last_http_code}" != "404" ]; then _hetznercloud_log_http_error "Hetzner Cloud zone lookup failed" "${_hetznercloud_last_http_code}" return 1 fi else return 1 fi encoded_candidate=$(printf "%s" "${candidate}" | _url_encode) if ! _hetznercloud_api GET "/zones?name=${encoded_candidate}"; then return 1 fi if [ "${_hetznercloud_last_http_code}" != "200" ]; then if [ "${_hetznercloud_last_http_code}" = "404" ]; then return 1 fi _hetznercloud_log_http_error "Hetzner Cloud zone search failed" "${_hetznercloud_last_http_code}" return 1 fi zone_data=$(_hetznercloud_extract_zone_from_list "${response}" "${candidate}") if [ -z "${zone_data}" ]; then return 1 fi if ! _hetznercloud_parse_zone_fields "${zone_data}"; then return 1 fi _savedomainconf "${zone_conf_key}" "${_hetznercloud_zone_id}" return 0 } _hetznercloud_parse_zone_fields() { zone_json="${1}" if [ -z "${zone_json}" ]; then return 1 fi normalized=$(printf "%s" "${zone_json}" | _normalizeJson) zone_id=$(printf "%s" "${normalized}" | _egrep_o '"id":[^,}]*' | _head_n 1 | cut -d : -f 2 | tr -d ' "') zone_name=$(printf "%s" "${normalized}" | _egrep_o '"name":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"') if [ -z "${zone_id}" ] || [ -z "${zone_name}" ]; then return 1 fi _hetznercloud_zone_id="${zone_id}" _hetznercloud_zone_name="${zone_name}" _hetznercloud_zone_name_lc=$(printf "%s" "${zone_name}" | sed 's/\.$//' | _lower_case) return 0 } _hetznercloud_extract_zone_from_list() { list_response=$(printf "%s" "${1}" | _normalizeJson) candidate="${2}" escaped_candidate=$(_hetznercloud_escape_regex "${candidate}") printf "%s" "${list_response}" | _egrep_o "{[^{}]*\"name\":\"${escaped_candidate}\"[^{}]*}" | _head_n 1 } _hetznercloud_escape_regex() { printf "%s" "${1}" | sed 's/\\/\\\\/g' | sed 's/\./\\./g' | sed 's/-/\\-/g' } _hetznercloud_get_rrset() { if [ -z "${_hetznercloud_zone_id}" ] || [ -z "${_hetznercloud_rrset_path}" ]; then return 1 fi if ! _hetznercloud_api GET "/zones/${_hetznercloud_zone_id}/rrsets/${_hetznercloud_rrset_path}/TXT"; then return 1 fi return 0 } _hetznercloud_rrset_contains_value() { wanted_value="${1}" normalized=$(printf "%s" "${response}" | _normalizeJson) escaped_value=$(_hetznercloud_escape_value "${wanted_value}") search_pattern="\"value\":\"\\\\\"${escaped_value}\\\\\"\"" if _contains "${normalized}" "${search_pattern}"; then return 0 fi return 1 } _hetznercloud_build_add_payload() { value="${1}" escaped_value=$(_hetznercloud_escape_value "${value}") printf '{"ttl":%s,"records":[{"value":"\\"%s\\""}]}' "${HETZNER_TTL}" "${escaped_value}" } _hetznercloud_build_remove_payload() { value="${1}" escaped_value=$(_hetznercloud_escape_value "${value}") printf '{"records":[{"value":"\\"%s\\""}]}' "${escaped_value}" } _hetznercloud_escape_value() { printf "%s" "${1}" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' } _hetznercloud_error_message() { if [ -z "${response}" ]; then return 1 fi message=$(printf "%s" "${response}" | _normalizeJson | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"') if [ -n "${message}" ]; then printf "%s" "${message}" return 0 fi return 1 } _hetznercloud_log_http_error() { context="${1}" code="${2}" message="$(_hetznercloud_error_message)" if [ -n "${context}" ]; then if [ -n "${message}" ]; then _err "${context} (HTTP ${code}): ${message}" else _err "${context} (HTTP ${code})" fi else if [ -n "${message}" ]; then _err "Hetzner Cloud DNS API error (HTTP ${code}): ${message}" else _err "Hetzner Cloud DNS API error (HTTP ${code})" fi fi } _hetznercloud_api() { method="${1}" ep="${2}" data="${3}" retried="${4}" if [ -z "${method}" ]; then method="GET" fi if ! _startswith "${ep}" "/"; then ep="/${ep}" fi url="${HETZNER_API}${ep}" export _H1="Authorization: Bearer ${HETZNER_TOKEN}" export _H2="Accept: application/json" export _H3="" export _H4="" export _H5="" : >"${HTTP_HEADER}" if [ "${method}" = "GET" ]; then response="$(_get "${url}")" else if [ -z "${data}" ]; then data="{}" fi response="$(_post "${data}" "${url}" "" "${method}" "application/json")" fi ret="${?}" _hetznercloud_last_http_code=$(grep "^HTTP" "${HTTP_HEADER}" | _tail_n 1 | cut -d " " -f 2 | tr -d '\r\n') if [ "${ret}" != "0" ]; then return 1 fi if [ "${_hetznercloud_last_http_code}" = "429" ] && [ "${retried}" != "retried" ]; then retry_after=$(grep -i "^Retry-After" "${HTTP_HEADER}" | _tail_n 1 | cut -d : -f 2 | tr -d ' \r') if [ -z "${retry_after}" ]; then retry_after=1 fi _info "Hetzner Cloud DNS API rate limit hit; retrying in ${retry_after} seconds." _sleep "${retry_after}" if ! _hetznercloud_api "${method}" "${ep}" "${data}" "retried"; then return 1 fi return 0 fi return 0 }