From 162fbde328e009688ffc84b860ba88f9cad3db6c Mon Sep 17 00:00:00 2001 From: jduss Date: Thu, 10 Sep 2026 15:37:03 +0200 Subject: [PATCH] Update netcup DNS API to support new API (#7214) * dns_netcup: add support for the new netcup REST API Domains managed by the new DNS backend can be handled through the new REST API at api.netcup.com. The API is selected by the length of NC_Apikey: new REST API keys are 64 characters long, legacy CCP API keys are 50. With a REST API key the domain is looked up via GET /v1/domain and the challenge record is managed through the dedicated ACME challenge endpoints. After adding a record, the script waits 20 seconds and then polls until the record reports the deployed status. Domains whose DNS cannot be managed via the REST API yet fall back to the legacy CCP API when NC_Apikey_Legacy, NC_Apipw and NC_CID are configured. * dns_netcup: treat non-challenge records as a no-op on the REST API The REST API can only manage _acme-challenge records, records with other names cannot exist behind it. The DNS-API-Test adds and removes a TXT record outside _acme-challenge and expects both calls to succeed, so treat such records as a successful no-op with an info message instead of failing. * dns_netcup: address review feedback for the REST API support - Only skip the synthetic DNS-API-Test record: real records without the _acme-challenge prefix (e.g. a challenge alias in the "=" form) now fail loudly, or use the legacy CCP API when legacy credentials are configured. The zone walk starts at the full name for them, so an apex alias is found. - Blank _H2..._H5 for REST API calls and clear all header slots before legacy CCP API calls so no auth headers leak between endpoints or dns hooks. - Stop walking the zone lookup when the API reports success:false and surface the response instead of a misleading "no zone found". - Split the response before extracting id/isDnsManaged so the egrep and sed implementations of _egrep_o cannot pick different matches. - Fall back to the legacy CCP API only on a literal isDnsManaged false; error distinctly on an unparsable value. - Poll the deploy status right away and sleep between retries instead of an unconditional 20 second sleep. - Use ${#NC_Apikey} for the key length and rename internal state to _nc_apikey/_nc_endrest. * dns_netcup: walk on when the REST API reports resourceDoesNotExist Querying /domain?fqdn= for a name that is not a domain of the account does not return an empty result: the API answers with success:false and the error code resourceDoesNotExist. Treat exactly that as "not found" during the zone walk and keep failing hard on everything else, e.g. an invalid API key. --- dnsapi/dns_netcup.sh | 400 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 359 insertions(+), 41 deletions(-) diff --git a/dnsapi/dns_netcup.sh b/dnsapi/dns_netcup.sh index 3b291854..87e9b22f 100644 --- a/dnsapi/dns_netcup.sh +++ b/dnsapi/dns_netcup.sh @@ -5,30 +5,315 @@ Domains: netcup.de netcup.net Site: netcup.eu/ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_netcup Options: - NC_Apikey API Key - NC_Apipw API Password - NC_CID Customer Number + NC_Apikey API Key. The new netcup REST API key (64 characters) or the legacy CCP API key + NC_Apipw API Password. Only used for the legacy CCP API + NC_CID Customer Number. Only used for the legacy CCP API + NC_Apikey_Legacy Legacy CCP API Key. Only used for domains not manageable via the REST API when NC_Apikey holds a new netcup REST API key. Optional. Author: linux-insideDE ' NC_Apikey="${NC_Apikey:-$(_readaccountconf_mutable NC_Apikey)}" NC_Apipw="${NC_Apipw:-$(_readaccountconf_mutable NC_Apipw)}" NC_CID="${NC_CID:-$(_readaccountconf_mutable NC_CID)}" +NC_Apikey_Legacy="${NC_Apikey_Legacy:-$(_readaccountconf_mutable NC_Apikey_Legacy)}" end="https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON" +_nc_endrest="https://api.netcup.com/v1" client="" dns_netcup_add() { - _debug NC_Apikey "$NC_Apikey" - _login - if [ "$NC_Apikey" = "" ] || [ "$NC_Apipw" = "" ] || [ "$NC_CID" = "" ]; then - _err "No Credentials given" + fulldomain=$1 + txtvalue=$2 + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _nc_check_credentials; then return 1 fi _saveaccountconf_mutable NC_Apikey "$NC_Apikey" - _saveaccountconf_mutable NC_Apipw "$NC_Apipw" - _saveaccountconf_mutable NC_CID "$NC_CID" + if [ -n "$NC_Apipw" ]; then + _saveaccountconf_mutable NC_Apipw "$NC_Apipw" + fi + if [ -n "$NC_CID" ]; then + _saveaccountconf_mutable NC_CID "$NC_CID" + fi + if [ -n "$NC_Apikey_Legacy" ]; then + _saveaccountconf_mutable NC_Apikey_Legacy "$NC_Apikey_Legacy" + fi + + if _nc_is_rest_key; then + _nc_rest_add "$fulldomain" "$txtvalue" + else + _nc_apikey="$NC_Apikey" + _nc_legacy_add "$fulldomain" "$txtvalue" + fi +} + +dns_netcup_rm() { fulldomain=$1 txtvalue=$2 + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _nc_check_credentials; then + return 1 + fi + + if _nc_is_rest_key; then + _nc_rest_rm "$fulldomain" "$txtvalue" + else + _nc_apikey="$NC_Apikey" + _nc_legacy_rm "$fulldomain" "$txtvalue" + fi +} + +#################### New netcup REST API (api.netcup.com) #################### + +_nc_rest_add() { + fulldomain=$1 + txtvalue=$2 + + if ! _nc_rest_get_domain "$fulldomain"; then + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _dns_managed "$_dns_managed" + + if [ "$_dns_managed" = "false" ]; then + _nc_rest_use_legacy "$_domain" || return 1 + _nc_legacy_add "$fulldomain" "$txtvalue" + return + fi + if [ "$_dns_managed" != "true" ]; then + _err "Unable to read isDnsManaged for $_domain from the netcup REST API response: $response" + return 1 + fi + + case "$fulldomain" in + _acme-challenge.*) ;; + acmetestXyzRandomName.*) + # The synthetic record of the DNS-API-Test, which expects add and + # rm to succeed. The REST API can only manage _acme-challenge + # records, so skip it. Real records are never treated as a no-op. + _info "Skipping the DNS-API-Test record $fulldomain, the netcup REST API can only manage _acme-challenge records." + return 0 + ;; + *) + # e.g. a challenge alias given in the "=" form without the prefix + if [ -n "$NC_Apikey_Legacy" ] && [ -n "$NC_Apipw" ] && [ -n "$NC_CID" ]; then + _debug "The netcup REST API can only create _acme-challenge records, using the legacy CCP API for $fulldomain" + _nc_apikey="$NC_Apikey_Legacy" + _nc_legacy_add "$fulldomain" "$txtvalue" + return + fi + _err "The netcup REST API can only create _acme-challenge records, unable to create $fulldomain." + _err "Set NC_Apikey_Legacy, NC_Apipw and NC_CID to manage it via the legacy CCP API." + return 1 + ;; + esac + + _nc_rest_get_scope "$fulldomain" "$_domain" + _debug _scope "$_scope" + + if ! _nc_rest POST "domain/$_domain_id/acme/challenge" "{\"scope\": \"$_scope\", \"value\": \"$txtvalue\"}" || + ! _contains "$response" '"success": *true'; then + _err "Unable to add the challenge record: $response" + return 1 + fi + + # The challenge record is added to the zone right away, but deploying + # the zone to the nameservers happens in the background, so poll until + # the record has actually been deployed (up to about 60 seconds). + _nc_tries=0 + while true; do + if _nc_rest GET "domain/$_domain_id/acme/challenge/$_scope/$txtvalue" && + _contains "$response" '"status": *"deployed"'; then + _info "The challenge record has been deployed" + return 0 + fi + _nc_tries=$(_math "$_nc_tries" + 1) + if [ "$_nc_tries" -ge 12 ]; then + break + fi + _debug "The challenge record has not been deployed yet, waiting 5 more seconds" + _sleep 5 + done + _info "The challenge record has still not been deployed after 60 seconds, continuing anyway" + return 0 +} + +_nc_rest_rm() { + fulldomain=$1 + txtvalue=$2 + + if ! _nc_rest_get_domain "$fulldomain"; then + return 1 + fi + + if [ "$_dns_managed" = "false" ]; then + _nc_rest_use_legacy "$_domain" || return 1 + _nc_legacy_rm "$fulldomain" "$txtvalue" + return + fi + if [ "$_dns_managed" != "true" ]; then + _err "Unable to read isDnsManaged for $_domain from the netcup REST API response: $response" + return 1 + fi + + case "$fulldomain" in + _acme-challenge.*) ;; + acmetestXyzRandomName.*) + # See _nc_rest_add. + _info "Skipping the DNS-API-Test record $fulldomain, the netcup REST API can only manage _acme-challenge records." + return 0 + ;; + *) + if [ -n "$NC_Apikey_Legacy" ] && [ -n "$NC_Apipw" ] && [ -n "$NC_CID" ]; then + _debug "The netcup REST API can only remove _acme-challenge records, using the legacy CCP API for $fulldomain" + _nc_apikey="$NC_Apikey_Legacy" + _nc_legacy_rm "$fulldomain" "$txtvalue" + return + fi + _err "The netcup REST API can only remove _acme-challenge records, unable to remove $fulldomain." + _err "Set NC_Apikey_Legacy, NC_Apipw and NC_CID to manage it via the legacy CCP API." + return 1 + ;; + esac + + _nc_rest_get_scope "$fulldomain" "$_domain" + + if ! _nc_rest DELETE "domain/$_domain_id/acme/challenge/$_scope/$txtvalue"; then + _err "Unable to remove the challenge record: $response" + return 1 + fi + _nc_status=$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d '\r\n') + _debug _nc_status "$_nc_status" + case "$_nc_status" in + 204) + return 0 + ;; + 404) + _info "The challenge record was not found, nothing to remove" + return 0 + ;; + *) + _err "Unable to remove the challenge record: $response" + return 1 + ;; + esac +} + +# fulldomain +# Sets _domain_id, _domain and _dns_managed of the domain the record +# belongs to, walking up the name, longest match first. For a challenge +# record the leftmost label is the prefix and can never be a zone, so +# the walk starts one label in. Other names (e.g. a challenge alias in +# the "=" form) may be a zone apex themselves. +_nc_rest_get_domain() { + case "$1" in + _acme-challenge.*) i=2 ;; + *) i=1 ;; + esac + while true; do + h=$(printf "%s" "$1" | cut -d . -f "$i"-100) + if [ -z "$h" ]; then + _nc_nozone "$1" + return 1 + fi + _debug h "$h" + if ! _nc_rest GET "domain?fqdn=$h"; then + return 1 + fi + if _contains "$response" '"success": *true'; then + if _contains "$response" '"fqdn"'; then + # split the response so that first/last match cannot differ + # between the egrep and sed implementations of _egrep_o + _domain_id=$(printf "%s" "$response" | tr '{,' '\n' | _egrep_o '"id": *[0-9][0-9]*' | _head_n 1 | tr -dc '0-9') + _dns_managed=$(printf "%s" "$response" | tr '{,' '\n' | _egrep_o '"isDnsManaged": *[a-z][a-z]*' | _head_n 1 | sed 's/.*: *//') + _domain="$h" + if [ -n "$_domain_id" ]; then + return 0 + fi + _err "Unable to parse the domain id from the netcup REST API response: $response" + return 1 + fi + # an empty result, $h is not a domain of this account: walk on + elif _contains "$response" '"code": *"resourceDoesNotExist"'; then + # the API reports a domain that is not in this account with + # success:false and this error code: walk on + _debug "$h is not a domain of this account" + else + # e.g. an invalid API key; do not walk on, it would end in a + # misleading "no zone found" error + _err "The netcup REST API request failed: $response" + _err "Note: NC_Apikey was detected as a netcup REST API key because it is 64 characters long." + return 1 + fi + i=$(_math "$i" + 1) + done +} + +# fulldomain domain +# Sets _scope to the host part of the challenge relative to the domain. +# The REST API prepends _acme-challenge. to the scope itself, so the +# prefix is stripped from the record name (the callers guarantee it is +# present). +_nc_rest_get_scope() { + _scope="${1#_acme-challenge.}" + if [ "$_scope" = "$2" ]; then + _scope="@" + else + _scope="${_scope%".$2"}" + fi +} + +# domain +# Selects the legacy credentials for a domain whose DNS cannot be +# managed via the new netcup REST API. +_nc_rest_use_legacy() { + _debug "The DNS of $1 cannot be managed via the REST API, using the legacy CCP API" + if [ -z "$NC_Apikey_Legacy" ] || [ -z "$NC_Apipw" ] || [ -z "$NC_CID" ]; then + _err "The DNS of the domain $1 cannot be managed via the new netcup REST API." + _err "Set NC_Apikey_Legacy, NC_Apipw and NC_CID to your legacy CCP API credentials to manage it." + return 1 + fi + _nc_apikey="$NC_Apikey_Legacy" +} + +# method endpoint [data] +# The response is returned in the global variable $response. +_nc_rest() { + m=$1 + ep=$2 + data=$3 + _debug2 "REST $m $ep" + + export _H1="Authorization: Bearer $NC_Apikey" + # blank the remaining header slots so that auth headers of another + # dns hook cannot ride into the netcup REST API in a multi-provider + # issuance + export _H2="" + export _H3="" + export _H4="" + export _H5="" + if [ "$m" = "GET" ]; then + response=$(_get "$_nc_endrest/$ep") + else + _debug2 data "$data" + response=$(_post "$data" "$_nc_endrest/$ep" "" "$m" "application/json") + fi + _nc_ret="$?" + _debug2 response "$response" + return "$_nc_ret" +} + +#################### Legacy CCP API (ccp.netcup.net) #################### + +_nc_legacy_add() { + fulldomain=$1 + txtvalue=$2 + if ! _nc_legacy_login; then + return 1 + fi domain="" exit=$(echo "$fulldomain" | tr -dc '.' | wc -c) exit=$(_math "$exit" + 1) @@ -46,7 +331,7 @@ dns_netcup_add() { domain="$tmp.$domain" fi if [ "$(_math "$i" - "$exit")" -ge 1 ]; then - msg=$(_post "{\"action\": \"updateDnsRecords\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\",\"clientrequestid\": \"$client\" , \"domainname\": \"$domain\", \"dnsrecordset\": { \"dnsrecords\": [ {\"id\": \"\", \"hostname\": \"$fulldomain.\", \"type\": \"TXT\", \"priority\": \"\", \"destination\": \"$txtvalue\", \"deleterecord\": \"false\", \"state\": \"yes\"} ]}}}" "$end" "" "POST") + msg=$(_post "{\"action\": \"updateDnsRecords\", \"param\": {\"apikey\": \"$_nc_apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\",\"clientrequestid\": \"$client\" , \"domainname\": \"$domain\", \"dnsrecordset\": { \"dnsrecords\": [ {\"id\": \"\", \"hostname\": \"$fulldomain.\", \"type\": \"TXT\", \"priority\": \"\", \"destination\": \"$txtvalue\", \"deleterecord\": \"false\", \"state\": \"yes\"} ]}}}" "$end" "" "POST") _debug "$msg" if [ "$(_getfield "$msg" "5" | sed 's/"statuscode"://g')" != 5028 ]; then if [ "$(_getfield "$msg" "4" | sed s/\"status\":\"//g | sed s/\"//g)" != "success" ]; then @@ -65,13 +350,15 @@ dns_netcup_add() { _nc_nozone "$fulldomain" return 1 fi - logout + _nc_legacy_logout } -dns_netcup_rm() { - _login +_nc_legacy_rm() { fulldomain=$1 txtvalue=$2 + if ! _nc_legacy_login; then + return 1 + fi domain="" exit=$(echo "$fulldomain" | tr -dc '.' | wc -c) @@ -91,7 +378,7 @@ dns_netcup_rm() { domain="$tmp.$domain" fi if [ "$(_math "$i" - "$exit")" -ge 1 ]; then - msg=$(_post "{\"action\": \"infoDnsRecords\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\", \"domainname\": \"$domain\"}}" "$end" "" "POST") + msg=$(_post "{\"action\": \"infoDnsRecords\", \"param\": {\"apikey\": \"$_nc_apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\", \"domainname\": \"$domain\"}}" "$end" "" "POST") rec=$(echo "$msg" | sed 's/\[//g' | sed 's/\]//g' | sed 's/{\"serverrequestid\".*\"dnsrecords\"://g' | sed 's/},{/};{/g' | sed 's/{//g' | sed 's/}//g') _debug "$msg" if [ "$(_getfield "$msg" "5" | sed 's/"statuscode"://g')" != 5028 ]; then @@ -132,21 +419,70 @@ dns_netcup_rm() { i=0 fi done - msg=$(_post "{\"action\": \"updateDnsRecords\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\",\"clientrequestid\": \"$client\" , \"domainname\": \"$domain\", \"dnsrecordset\": { \"dnsrecords\": [ {\"id\": \"$ids\", \"hostname\": \"$fulldomain.\", \"type\": \"TXT\", \"priority\": \"\", \"destination\": \"$txtvalue\", \"deleterecord\": \"TRUE\", \"state\": \"yes\"} ]}}}" "$end" "" "POST") + msg=$(_post "{\"action\": \"updateDnsRecords\", \"param\": {\"apikey\": \"$_nc_apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\",\"clientrequestid\": \"$client\" , \"domainname\": \"$domain\", \"dnsrecordset\": { \"dnsrecords\": [ {\"id\": \"$ids\", \"hostname\": \"$fulldomain.\", \"type\": \"TXT\", \"priority\": \"\", \"destination\": \"$txtvalue\", \"deleterecord\": \"TRUE\", \"state\": \"yes\"} ]}}}" "$end" "" "POST") _debug "$msg" if [ "$(_getfield "$msg" "4" | sed s/\"status\":\"//g | sed s/\"//g)" != "success" ]; then _err "$msg" return 1 fi - logout + _nc_legacy_logout } -# The zone is looked up by walking the challenge name from the right, one -# label at a time. The leftmost label is the challenge prefix, so the full -# name itself can never be a zone: asking netcup for it only returns 4013 -# "Validation Error", which would then mask the real 5028 "zone could not be -# found". Stop one label short, unless the name is too short to have a -# challenge prefix at all (manual invocation). +_nc_legacy_login() { + # never send the REST API Bearer header (or auth headers of another + # dns hook) to the legacy CCP API + export _H1="" + export _H2="" + export _H3="" + export _H4="" + export _H5="" + tmp=$(_post "{\"action\": \"login\", \"param\": {\"apikey\": \"$_nc_apikey\", \"apipassword\": \"$NC_Apipw\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST") + sid=$(echo "$tmp" | tr '{}' '\n' | grep apisessionid | cut -d '"' -f 4) + _debug "$tmp" + if [ "$(_getfield "$tmp" "4" | sed s/\"status\":\"//g | sed s/\"//g)" != "success" ]; then + _err "$tmp" + return 1 + fi +} + +_nc_legacy_logout() { + tmp=$(_post "{\"action\": \"logout\", \"param\": {\"apikey\": \"$_nc_apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST") + _debug "$tmp" + if [ "$(_getfield "$tmp" "4" | sed s/\"status\":\"//g | sed s/\"//g)" != "success" ]; then + _err "$tmp" + return 1 + fi +} + +#################### Shared helpers #################### + +_nc_check_credentials() { + if [ -z "$NC_Apikey" ]; then + _err "No Credentials given" + _err "Set NC_Apikey to your netcup REST API key (64 characters) or your legacy CCP API key." + return 1 + fi + if ! _nc_is_rest_key; then + if [ -z "$NC_Apipw" ] || [ -z "$NC_CID" ]; then + _err "No Credentials given" + _err "The legacy CCP API needs NC_Apikey, NC_Apipw and NC_CID." + return 1 + fi + fi +} + +# New netcup REST API keys are 64 characters long, legacy CCP API keys +# are 50, so the key length selects the API. +_nc_is_rest_key() { + [ "${#NC_Apikey}" -eq 64 ] +} + +# The legacy zone lookup walks the challenge name from the right, one +# label at a time. The leftmost label is the challenge prefix, so the +# full name itself can never be a zone: asking netcup for it only +# returns 4013 "Validation Error", which would then mask the real 5028 +# "zone could not be found". Stop one label short, unless the name is +# too short to have a challenge prefix at all (manual invocation). # levels _nc_lastlevel() { if [ "$1" -ge 3 ]; then @@ -159,23 +495,5 @@ _nc_lastlevel() { # fulldomain _nc_nozone() { _err "No DNS zone for $1 was found at netcup." - _err "Check that the domain belongs to the account of the configured NC_CID and that its DNS is hosted at netcup." -} - -_login() { - tmp=$(_post "{\"action\": \"login\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apipassword\": \"$NC_Apipw\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST") - sid=$(echo "$tmp" | tr '{}' '\n' | grep apisessionid | cut -d '"' -f 4) - _debug "$tmp" - if [ "$(_getfield "$tmp" "4" | sed s/\"status\":\"//g | sed s/\"//g)" != "success" ]; then - _err "$tmp" - return 1 - fi -} -logout() { - tmp=$(_post "{\"action\": \"logout\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST") - _debug "$tmp" - if [ "$(_getfield "$tmp" "4" | sed s/\"status\":\"//g | sed s/\"//g)" != "success" ]; then - _err "$tmp" - return 1 - fi + _err "Check that the domain belongs to the account of the configured credentials and that its DNS is hosted at netcup." }