From 618735d11e6c8cdb0de26db7ca8e0e5de71ef08d Mon Sep 17 00:00:00 2001 From: Jordan Russell Date: Thu, 9 Apr 2026 02:07:30 +1200 Subject: [PATCH] [dnsapi] add SiteHost DNS API hook (#6891) Co-authored-by: Jordan Russell --- dnsapi/dns_sitehost.sh | 220 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100755 dnsapi/dns_sitehost.sh diff --git a/dnsapi/dns_sitehost.sh b/dnsapi/dns_sitehost.sh new file mode 100755 index 00000000..94a0ee93 --- /dev/null +++ b/dnsapi/dns_sitehost.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_sitehost_info='SiteHost +Site: sitehost.nz +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_sitehost +Options: + SITEHOST_API_KEY API Key + SITEHOST_CLIENT_ID Client ID. The numeric client ID for your SiteHost account. +Issues: github.com/acmesh-official/acme.sh/issues/6892 +Author: Jordan Russell +' + +SITEHOST_API="https://api.sitehost.nz/1.5" + +######## Public functions ##################### + +# Usage: dns_sitehost_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_sitehost_add() { + fulldomain=$1 + txtvalue=$2 + + if ! _sitehost_load_creds; then + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + # SiteHost expects the full record name as the name parameter + _info "Adding TXT record for ${fulldomain}" + if _sitehost_rest POST "dns/add_record.json" "client_id=$(printf '%s' "${SITEHOST_CLIENT_ID}" | _url_encode)&domain=$(printf '%s' "${_domain}" | _url_encode)&type=TXT&name=$(printf '%s' "${fulldomain}" | _url_encode)&content=$(printf '%s' "${txtvalue}" | _url_encode)"; then + if _contains "$response" '"status":true'; then + _info "TXT record added successfully." + return 0 + fi + fi + + _err "Could not add TXT record for ${fulldomain}" + _err "$response" + return 1 +} + +# Usage: dns_sitehost_rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +# Remove the txt record after validation. +dns_sitehost_rm() { + fulldomain=$1 + txtvalue=$2 + + if ! _sitehost_load_creds; then + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _debug "Getting TXT records for ${_domain}" + if ! _sitehost_rest GET "dns/list_records.json" "client_id=$(printf '%s' "${SITEHOST_CLIENT_ID}" | _url_encode)&domain=$(printf '%s' "${_domain}" | _url_encode)"; then + _err "Could not list DNS records" + _err "$response" + return 1 + fi + + if ! _contains "$response" '"status":true'; then + _err "Error listing DNS records" + _err "$response" + return 1 + fi + + # Extract record ID matching our fulldomain, type TXT, and txtvalue + # Response format: {"return":[{"id":"123","name":"...","type":"TXT","content":"..."},...]} + # SiteHost returns flat single-line JSON objects in the records array + # Escape regex metacharacters in values before grep matching + _fulldomain_grep="$(printf "%s" "$fulldomain" | sed 's/[][\\.^$*]/\\&/g')" + _txtvalue_grep="$(printf "%s" "$txtvalue" | sed 's/[][\\.^$*]/\\&/g')" + # Use field-specific matching to avoid false positives from substring matches + _record_id="$(echo "$response" | _egrep_o '\{[^}]*\}' | grep '"name" *: *"'"${_fulldomain_grep}"'"' | grep '"type" *: *"TXT"' | grep '"content" *: *"'"${_txtvalue_grep}"'"' | _head_n 1 | _egrep_o '"id" *: *"?[0-9]+"?' | _egrep_o '[0-9]+')" + + if [ -z "$_record_id" ]; then + _info "TXT record not found, nothing to remove." + return 0 + fi + + _debug _record_id "$_record_id" + + _info "Deleting TXT record ${_record_id} for ${fulldomain}" + if _sitehost_rest POST "dns/delete_record.json" "client_id=$(printf '%s' "${SITEHOST_CLIENT_ID}" | _url_encode)&domain=$(printf '%s' "${_domain}" | _url_encode)&record_id=$(printf '%s' "${_record_id}" | _url_encode)"; then + if _contains "$response" '"status":true'; then + _info "TXT record deleted successfully." + return 0 + fi + fi + + _err "Could not delete TXT record for ${fulldomain}" + _err "$response" + return 1 +} + +#################### Private functions below ################################## + +_sitehost_load_creds() { + SITEHOST_API_KEY="${SITEHOST_API_KEY:-$(_readaccountconf_mutable SITEHOST_API_KEY)}" + SITEHOST_CLIENT_ID="${SITEHOST_CLIENT_ID:-$(_readaccountconf_mutable SITEHOST_CLIENT_ID)}" + + if [ -z "$SITEHOST_API_KEY" ] || [ -z "$SITEHOST_CLIENT_ID" ]; then + SITEHOST_API_KEY="" + SITEHOST_CLIENT_ID="" + _err "You didn't specify SITEHOST_API_KEY and/or SITEHOST_CLIENT_ID." + _err "Please export them and try again." + return 1 + fi + + _saveaccountconf_mutable SITEHOST_API_KEY "$SITEHOST_API_KEY" + _saveaccountconf_mutable SITEHOST_CLIENT_ID "$SITEHOST_CLIENT_ID" + return 0 +} + +#_acme-challenge.www.domain.com +#returns +# _sub_domain=_acme-challenge.www +# _domain=domain.com +_get_root() { + domain=$1 + + _debug "Getting domain list" + + # Fetch ALL pages of domains first so we can match the most specific zone + # (a more specific zone on a later page must take precedence over a broader match) + _all_domains="" + _page=1 + + while true; do + if ! _sitehost_rest GET "dns/list_domains.json" "client_id=$(printf '%s' "${SITEHOST_CLIENT_ID}" | _url_encode)&filters%5Bpage_number%5D=${_page}"; then + _err "Could not list domains" + return 1 + fi + + if ! _contains "$response" '"status":true'; then + _err "Error listing domains" + _err "$response" + return 1 + fi + + _all_domains="${_all_domains} ${response}" + + _total_pages=$(echo "$response" | _egrep_o '"total_pages" *: *[0-9]+' | _egrep_o '[0-9]+') + if [ -z "$_total_pages" ] || [ "$_page" -ge "$_total_pages" ]; then + break + fi + + _page=$(_math "$_page" + 1) + done + + # Try each subdomain level, most specific first + _i=1 + _p=1 + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "${_i}"-100) + _debug h "$h" + if [ -z "$h" ]; then + return 1 + fi + + if echo "$_all_domains" | grep -F "\"${h}\"" >/dev/null 2>&1; then + if [ "$_i" = "1" ]; then + # DNS alias mode - fulldomain is the zone itself + _sub_domain="" + else + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"${_p}") + fi + _domain="${h}" + return 0 + fi + + _p="${_i}" + _i=$(_math "$_i" + 1) + done + + return 1 +} + +# Usage: _sitehost_rest method endpoint data +_sitehost_rest() { + m="$1" + ep="$2" + data="$3" + url="${SITEHOST_API}/${ep}" + + _debug url "$url" + + _apikey="$(printf "%s" "${SITEHOST_API_KEY}" | _url_encode)" + + if [ "$m" = "GET" ]; then + response="$(_get "${url}?apikey=${_apikey}&${data}")" + else + _debug2 data "$data" + response="$(_post "apikey=${_apikey}&${data}" "$url")" + fi + + if [ "$?" != "0" ]; then + _err "error ${ep}" + return 1 + fi + + response="$(printf '%s' "$response" | tr -d '\r')" + + _debug2 response "$response" + return 0 +}