acme.sh/dnsapi/dns_namecheap.sh
neil f67be78ff4 Fix dns_namecheap ignoring IsOurDNS when matching the root zone
_get_root_by_getList() matched the candidate suffix as an unanchored
substring of the whole domains.getList response and never looked at the
IsOurDNS attribute. A domain parked on Namecheap's webhosting DNS is
listed with IsOurDNS="false", yet it was still accepted as the root zone,
so _get_root() returned success and the domains.dns.getHosts probe that
would have found the real zone never ran. Every following getHosts call
was then refused with error 2030288 "not using proper DNS servers" and
the challenge failed with "invalid tld".

Match the exact <Domain Name="..."> entry instead and require
IsOurDNS="true", so a subdomain delegated to Namecheap BasicDNS/FreeDNS
under a parent that is not on Namecheap DNS now resolves to its own zone.
Matching the entry exactly also drops the old substring/regex match, in
which the dots of a domain matched any character.

Fixes #7178
2026-08-06 19:36:26 +08:00

450 lines
11 KiB
Bash
Executable file

#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_namecheap_info='NameCheap.com
Site: NameCheap.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_namecheap
Options:
NAMECHEAP_API_KEY API Key
NAMECHEAP_USERNAME Username
NAMECHEAP_SOURCEIP Source IP
Issues: github.com/acmesh-official/acme.sh/issues/2107
'
# Namecheap API
# https://www.namecheap.com/support/api/intro.aspx
# Due to Namecheap's API limitation all the records of your domain will be read and re applied, make sure to have a backup of your records you could apply if any issue would arise.
######## Public functions #####################
NAMECHEAP_API="https://api.namecheap.com/xml.response"
#Usage: dns_namecheap_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_namecheap_add() {
fulldomain=$1
txtvalue=$2
if ! _namecheap_check_config; then
_err "$error"
return 1
fi
if ! _namecheap_set_publicip; then
return 1
fi
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "invalid domain"
return 1
fi
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
_debug domain "$_domain"
_debug sub_domain "$_sub_domain"
_set_namecheap_TXT "$_domain" "$_sub_domain" "$txtvalue"
}
#Usage: fulldomain txtvalue
#Remove the txt record after validation.
dns_namecheap_rm() {
fulldomain=$1
txtvalue=$2
if ! _namecheap_set_publicip; then
return 1
fi
if ! _namecheap_check_config; then
_err "$error"
return 1
fi
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "invalid domain"
return 1
fi
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
_debug domain "$_domain"
_debug sub_domain "$_sub_domain"
_del_namecheap_TXT "$_domain" "$_sub_domain" "$txtvalue"
}
#################### Private functions below ##################################
#_acme-challenge.www.domain.com
#returns
# _sub_domain=_acme-challenge.www
# _domain=domain.com
_get_root() {
fulldomain=$1
if ! _get_root_by_getList "$fulldomain"; then
_debug "Failed domain lookup via domains.getList api call. Trying domain lookup via domains.dns.getHosts api."
# The above "getList" api will only return hosts *owned* by the calling user. However, if the calling
# user is not the owner, but still has administrative rights, we must query the getHosts api directly.
# See this comment and the official namecheap response: https://disq.us/p/1q6v9x9
if ! _get_root_by_getHosts "$fulldomain"; then
return 1
fi
fi
return 0
}
_get_root_by_getList() {
domain=$1
if ! _namecheap_post "namecheap.domains.getList"; then
_err "$error"
return 1
fi
_namecheap_domain_list=$(echo "$response" | _egrep_o '<Domain [^>]*')
_debug2 domain_list "$_namecheap_domain_list"
i=2
p=1
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
_debug h "$h"
if [ -z "$h" ]; then
#not valid
return 1
fi
if ! _contains "$h" "\\."; then
#not valid
return 1
fi
if ! _namecheap_is_our_dns "$h"; then
_debug "$h not found"
else
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
_domain="$h"
return 0
fi
p="$i"
i=$(_math "$i" + 1)
done
return 1
}
#Usage: _namecheap_is_our_dns <domain>
#Succeeds only when domains.getList listed exactly <domain> AND that entry is
#served by Namecheap's own DNS. A domain parked on Namecheap's webhosting DNS
#is listed with IsOurDNS="false", and every dns.getHosts/setHosts call against
#it is refused with error 2030288 "not using proper DNS servers". Accepting
#such a domain as the root zone hides a subdomain that IS delegated to
#Namecheap DNS and that the getHosts probe below would have found.
#https://github.com/acmesh-official/acme.sh/issues/7178
_namecheap_is_our_dns() {
_namecheap_entry=$(echo "$_namecheap_domain_list" | grep -F " Name=\"$1\"" | _head_n 1)
if [ -z "$_namecheap_entry" ]; then
return 1
fi
_namecheap_ourdns=$(echo "$_namecheap_entry" | _egrep_o ' IsOurDNS="[^"]*' | cut -d '"' -f 2)
_debug2 "$1 IsOurDNS" "$_namecheap_ourdns"
if [ "$_namecheap_ourdns" = "true" ]; then
return 0
fi
return 1
}
_get_root_by_getHosts() {
i=100
p=99
while [ "$p" -ne 0 ]; do
h=$(printf "%s" "$1" | cut -d . -f "$i"-100)
if [ -n "$h" ]; then
if _contains "$h" "\\."; then
_debug h "$h"
if _namecheap_set_tld_sld "$h"; then
_sub_domain=$(printf "%s" "$1" | cut -d . -f 1-"$p")
_domain="$h"
return 0
else
_debug "$h not found"
fi
fi
fi
i="$p"
p=$(_math "$p" - 1)
done
return 1
}
_namecheap_set_publicip() {
if [ -z "$NAMECHEAP_SOURCEIP" ]; then
_err "No Source IP specified for Namecheap API."
_err "Use your public ip address or an url to retrieve it (e.g. https://ifconfig.co/ip) and export it as NAMECHEAP_SOURCEIP"
return 1
else
_saveaccountconf NAMECHEAP_SOURCEIP "$NAMECHEAP_SOURCEIP"
_debug sourceip "$NAMECHEAP_SOURCEIP"
ip=$(echo "$NAMECHEAP_SOURCEIP" | _egrep_o '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}')
addr=$(echo "$NAMECHEAP_SOURCEIP" | _egrep_o '(http|https):\/\/.*')
_debug2 ip "$ip"
_debug2 addr "$addr"
if [ -n "$ip" ]; then
_publicip="$ip"
elif [ -n "$addr" ]; then
_publicip=$(_get "$addr")
else
_err "No Source IP specified for Namecheap API."
_err "Use your public ip address or an url to retrieve it (e.g. https://ifconfig.co/ip) and export it as NAMECHEAP_SOURCEIP"
return 1
fi
fi
_debug publicip "$_publicip"
return 0
}
_namecheap_post() {
command=$1
data="ApiUser=${NAMECHEAP_USERNAME}&ApiKey=${NAMECHEAP_API_KEY}&ClientIp=${_publicip}&UserName=${NAMECHEAP_USERNAME}&Command=${command}"
_debug2 "_namecheap_post data" "$data"
response="$(_post "$data" "$NAMECHEAP_API" "" "POST")"
_debug2 response "$response"
if _contains "$response" "Status=\"ERROR\"" >/dev/null; then
error=$(echo "$response" | _egrep_o ">.*<\\/Error>" | cut -d '<' -f 1 | tr -d '>')
_err "error $error"
return 1
fi
return 0
}
_namecheap_parse_host() {
_host=$1
_debug _host "$_host"
_hostid=$(echo "$_host" | _egrep_o ' HostId="[^"]*' | cut -d '"' -f 2)
_hostname=$(echo "$_host" | _egrep_o ' Name="[^"]*' | cut -d '"' -f 2)
_hosttype=$(echo "$_host" | _egrep_o ' Type="[^"]*' | cut -d '"' -f 2)
_hostaddress=$(echo "$_host" | _egrep_o ' Address="[^"]*' | cut -d '"' -f 2 | _xml_decode)
_hostmxpref=$(echo "$_host" | _egrep_o ' MXPref="[^"]*' | cut -d '"' -f 2)
_hostttl=$(echo "$_host" | _egrep_o ' TTL="[^"]*' | cut -d '"' -f 2)
_debug hostid "$_hostid"
_debug hostname "$_hostname"
_debug hosttype "$_hosttype"
_debug hostaddress "$_hostaddress"
_debug hostmxpref "$_hostmxpref"
_debug hostttl "$_hostttl"
}
_namecheap_check_config() {
if [ -z "$NAMECHEAP_API_KEY" ]; then
_err "No API key specified for Namecheap API."
_err "Create your key and export it as NAMECHEAP_API_KEY"
return 1
fi
if [ -z "$NAMECHEAP_USERNAME" ]; then
_err "No username key specified for Namecheap API."
_err "Create your key and export it as NAMECHEAP_USERNAME"
return 1
fi
_saveaccountconf NAMECHEAP_API_KEY "$NAMECHEAP_API_KEY"
_saveaccountconf NAMECHEAP_USERNAME "$NAMECHEAP_USERNAME"
return 0
}
_set_namecheap_TXT() {
subdomain=$2
txt=$3
if ! _namecheap_set_tld_sld "$1"; then
return 1
fi
request="namecheap.domains.dns.getHosts&SLD=${_sld}&TLD=${_tld}"
if ! _namecheap_post "$request"; then
_err "$error"
return 1
fi
hosts=$(echo "$response" | _egrep_o '<host[^>]*')
_debug hosts "$hosts"
if [ -z "$hosts" ]; then
# An empty host list is only acceptable when the API positively confirms
# a successful getHosts reply: setHosts below REPLACES all records, so
# proceeding on a malformed/unparsed response would wipe the whole zone.
# https://github.com/acmesh-official/acme.sh/issues/6963
if _contains "$response" "Status=\"OK\"" && _contains "$response" "DomainDNSGetHostsResult"; then
_debug "No existing host records, adding the TXT record as the first one"
else
_err "Hosts not found"
return 1
fi
fi
_namecheap_reset_hostList
while read -r host; do
if _contains "$host" "<host"; then
_namecheap_parse_host "$host"
_debug2 _hostname "_hostname"
_debug2 _hosttype "_hosttype"
_debug2 _hostaddress "_hostaddress"
_debug2 _hostmxpref "_hostmxpref"
_hostaddress="$(printf "%s" "$_hostaddress" | _url_encode)"
_debug2 "encoded _hostaddress" "_hostaddress"
_namecheap_add_host "$_hostname" "$_hosttype" "$_hostaddress" "$_hostmxpref" "$_hostttl"
fi
done <<EOT
echo "$hosts"
EOT
_namecheap_add_host "$subdomain" "TXT" "$txt" 10 120
_debug hostrequestfinal "$_hostrequest"
request="namecheap.domains.dns.setHosts&SLD=${_sld}&TLD=${_tld}${_hostrequest}"
if ! _namecheap_post "$request"; then
_err "$error"
return 1
fi
return 0
}
_del_namecheap_TXT() {
subdomain=$2
txt=$3
if ! _namecheap_set_tld_sld "$1"; then
return 1
fi
request="namecheap.domains.dns.getHosts&SLD=${_sld}&TLD=${_tld}"
if ! _namecheap_post "$request"; then
_err "$error"
return 1
fi
hosts=$(echo "$response" | _egrep_o '<host[^>]*')
_debug hosts "$hosts"
if [ -z "$hosts" ]; then
_err "Hosts not found"
return 1
fi
_namecheap_reset_hostList
found=0
while read -r host; do
if _contains "$host" "<host"; then
_namecheap_parse_host "$host"
if [ "$_hosttype" = "TXT" ] && [ "$_hostname" = "$subdomain" ] && [ "$_hostaddress" = "$txt" ]; then
_debug "TXT entry found"
found=1
else
_hostaddress="$(printf "%s" "$_hostaddress" | _url_encode)"
_namecheap_add_host "$_hostname" "$_hosttype" "$_hostaddress" "$_hostmxpref" "$_hostttl"
fi
fi
done <<EOT
echo "$hosts"
EOT
if [ $found -eq 0 ]; then
_debug "TXT entry not found"
return 0
fi
_debug hostrequestfinal "$_hostrequest"
request="namecheap.domains.dns.setHosts&SLD=${_sld}&TLD=${_tld}${_hostrequest}"
if ! _namecheap_post "$request"; then
_err "$error"
return 1
fi
return 0
}
_namecheap_reset_hostList() {
_hostindex=0
_hostrequest=""
}
#Usage: _namecheap_add_host HostName RecordType Address MxPref TTL
_namecheap_add_host() {
_hostindex=$(_math "$_hostindex" + 1)
_hostrequest=$(printf '%s&HostName%d=%s&RecordType%d=%s&Address%d=%s&MXPref%d=%d&TTL%d=%d' "$_hostrequest" "$_hostindex" "$1" "$_hostindex" "$2" "$_hostindex" "$3" "$_hostindex" "$4" "$_hostindex" "$5")
}
_namecheap_set_tld_sld() {
domain=$1
_tld=""
_sld=""
i=2
while true; do
_tld=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
_debug tld "$_tld"
if [ -z "$_tld" ]; then
_debug "invalid tld"
return 1
fi
j=$(_math "$i" - 1)
_sld=$(printf "%s" "$domain" | cut -d . -f 1-"$j")
_debug sld "$_sld"
if [ -z "$_sld" ]; then
_debug "invalid sld"
return 1
fi
request="namecheap.domains.dns.getHosts&SLD=$_sld&TLD=$_tld"
if ! _namecheap_post "$request"; then
_debug "sld($_sld)/tld($_tld) not found"
else
_debug "sld($_sld)/tld($_tld) found"
return 0
fi
i=$(_math "$i" + 1)
done
}
_xml_decode() {
sed 's/&quot;/"/g'
}