Files
jduss 162fbde328 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.
2026-09-10 21:37:03 +08:00

500 lines
16 KiB
Bash

#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_netcup_info='netcup.eu
Domains: netcup.de netcup.net
Site: netcup.eu/
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_netcup
Options:
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() {
fulldomain=$1
txtvalue=$2
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
if ! _nc_check_credentials; then
return 1
fi
_saveaccountconf_mutable NC_Apikey "$NC_Apikey"
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)
i=$exit
_nc_last=$(_nc_lastlevel "$i")
_nc_found=""
while
[ "$exit" -ge "$_nc_last" ]
do
tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit")
if [ "$(_math "$i" - "$exit")" -eq 0 ]; then
domain="$tmp"
else
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")
_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
_err "$msg"
return 1
else
_nc_found=1
break
fi
fi
fi
exit=$(_math "$exit" - 1)
done
if [ -z "$_nc_found" ]; then
_err "$msg"
_nc_nozone "$fulldomain"
return 1
fi
_nc_legacy_logout
}
_nc_legacy_rm() {
fulldomain=$1
txtvalue=$2
if ! _nc_legacy_login; then
return 1
fi
domain=""
exit=$(echo "$fulldomain" | tr -dc '.' | wc -c)
exit=$(_math "$exit" + 1)
i=$exit
rec=""
_nc_last=$(_nc_lastlevel "$i")
_nc_found=""
while
[ "$exit" -ge "$_nc_last" ]
do
tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit")
if [ "$(_math "$i" - "$exit")" -eq 0 ]; then
domain="$tmp"
else
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")
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
if [ "$(_getfield "$msg" "4" | sed s/\"status\":\"//g | sed s/\"//g)" != "success" ]; then
_err "$msg"
return 1
else
_nc_found=1
break
fi
fi
fi
exit=$(_math "$exit" - 1)
done
if [ -z "$_nc_found" ]; then
_err "$msg"
_nc_nozone "$fulldomain"
return 1
fi
ida=0000
idv=0001
ids=0000000000
i=1
while
[ "$i" -ne 0 ]
do
specrec=$(_getfield "$rec" "$i" ";")
idv="$ida"
ida=$(_getfield "$specrec" "1" "," | sed 's/\"id\":\"//g' | sed 's/\"//g')
txtv=$(_getfield "$specrec" "5" "," | sed 's/\"destination\":\"//g' | sed 's/\"//g')
i=$(_math "$i" + 1)
if [ "$txtvalue" = "$txtv" ]; then
i=0
ids="$ida"
fi
if [ "$ida" = "$idv" ]; then
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")
_debug "$msg"
if [ "$(_getfield "$msg" "4" | sed s/\"status\":\"//g | sed s/\"//g)" != "success" ]; then
_err "$msg"
return 1
fi
_nc_legacy_logout
}
_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
echo 2
else
echo 1
fi
}
# 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 credentials and that its DNS is hosted at netcup."
}