diff --git a/dnsapi/dns_bhosted.sh b/dnsapi/dns_bhosted.sh new file mode 100644 index 0000000000..41307fbc73 --- /dev/null +++ b/dnsapi/dns_bhosted.sh @@ -0,0 +1,358 @@ +#!/usr/bin/env sh + +# shellcheck disable=SC2034 +dns_bhosted_info='bHosted.nl DNS API +Site: bHosted.nl +Docs: Custom dnsapi plugin for acme.sh +Options: + BHOSTED_Username API username + BHOSTED_Password API password (MD5 hash zoals bHosted webservices voorbeeld) + BHOSTED_TTL TTL for TXT record (default: 300) + BHOSTED_SLD Optional override (handig voor multi-part TLDs zoals co.uk) + BHOSTED_TLD Optional override (handig voor multi-part TLDs zoals co.uk) +Notes: + - Plugin gebruikt addrecord + delrecord voor DNS-01 challenge + - Record ID wordt uit addrecord XML response gehaald en gecached voor cleanup +' + +BHOSTED_API_ROOT="https://webservices.bhosted.nl/dns" + +############ Public functions ##################### + +# Usage: dns_bhosted_add _acme-challenge.www.example.com "txt-value" +dns_bhosted_add() { + fulldomain="$1" + txtvalue="$2" + + _debug "fulldomain" "$fulldomain" + _debug "txtvalue" "$txtvalue" + + _bhosted_load_credentials || return 1 + _bhosted_get_root "$fulldomain" || return 1 + + _info "Adding TXT record: ${_bhosted_name}.${_domain}" + + BHOSTED_TTL="${BHOSTED_TTL:-$(_readaccountconf_mutable BHOSTED_TTL)}" + BHOSTED_TTL="${BHOSTED_TTL:-300}" + _saveaccountconf_mutable BHOSTED_TTL "$BHOSTED_TTL" + + _bhosted_api_add_txt "$_bhosted_sld" "$_bhosted_tld" "$_bhosted_name" "$txtvalue" "$BHOSTED_TTL" || return 1 + + # Extract and store record id for later cleanup + _rec_id="$(_bhosted_extract_id "$response")" + if [ -n "$_rec_id" ]; then + _cache_key="$(_bhosted_cache_key "$fulldomain" "$txtvalue")" + _debug "_cache_key" "$_cache_key" + _debug "_rec_id" "$_rec_id" + _saveaccountconf_mutable "$_cache_key" "$_rec_id" + else + _err "TXT record added but no record id found in response." + _err "Cleanup may fail unless bHosted addrecord returns ...." + _debug2 "add response" "$response" + return 1 + fi + + return 0 +} + +# Usage: dns_bhosted_rm _acme-challenge.www.example.com "txt-value" +dns_bhosted_rm() { + fulldomain="$1" + txtvalue="$2" + + _debug "fulldomain" "$fulldomain" + _debug "txtvalue" "$txtvalue" + + _bhosted_load_credentials || return 1 + _bhosted_get_root "$fulldomain" || return 1 + + _cache_key="$(_bhosted_cache_key "$fulldomain" "$txtvalue")" + _rec_id="$(_readaccountconf_mutable "$_cache_key")" + + if [ -z "$_rec_id" ]; then + _err "No cached bHosted record id found for cleanup." + _err "Please delete TXT manually in bHosted DNS for: ${_bhosted_name}.${_domain}" + return 1 + fi + + _info "Removing TXT record id=${_rec_id}: ${_bhosted_name}.${_domain}" + _bhosted_api_del_record "$_bhosted_sld" "$_bhosted_tld" "$_rec_id" || return 1 + + # Clear cached id after successful delete + _saveaccountconf_mutable "$_cache_key" "" + + return 0 +} + +######## Private functions ##################### + +_bhosted_load_credentials() { + BHOSTED_Username="${BHOSTED_Username:-$(_readaccountconf_mutable BHOSTED_Username)}" + BHOSTED_Password="${BHOSTED_Password:-$(_readaccountconf_mutable BHOSTED_Password)}" + + if [ -z "$BHOSTED_Username" ] || [ -z "$BHOSTED_Password" ]; then + BHOSTED_Username="" + BHOSTED_Password="" + _err "You didn't specify bHosted credentials." + _err "Please export BHOSTED_Username and BHOSTED_Password (MD5 hash)." + return 1 + fi + + _saveaccountconf_mutable BHOSTED_Username "$BHOSTED_Username" + _saveaccountconf_mutable BHOSTED_Password "$BHOSTED_Password" + + return 0 +} + +# Determine root zone and host part +# Supports simple domains automatically (example.com, example.nl) +# For multi-part TLDs (example.co.uk), set: +# BHOSTED_SLD=example +# BHOSTED_TLD=co.uk +_bhosted_get_root() { + domain="$1" + + BHOSTED_SLD="${BHOSTED_SLD:-$(_readaccountconf_mutable BHOSTED_SLD)}" + BHOSTED_TLD="${BHOSTED_TLD:-$(_readaccountconf_mutable BHOSTED_TLD)}" + + if [ -n "$BHOSTED_SLD" ] && [ -n "$BHOSTED_TLD" ]; then + _saveaccountconf_mutable BHOSTED_SLD "$BHOSTED_SLD" + _saveaccountconf_mutable BHOSTED_TLD "$BHOSTED_TLD" + + _domain="${BHOSTED_SLD}.${BHOSTED_TLD}" + case "$domain" in + *."$_domain") ;; + "$_domain") ;; + *) + _err "BHOSTED_SLD/BHOSTED_TLD do not match requested domain: $domain" + return 1 + ;; + esac + + _bhosted_sld="$BHOSTED_SLD" + _bhosted_tld="$BHOSTED_TLD" + _bhosted_name="${domain%.$_domain}" + if [ "$_bhosted_name" = "$domain" ]; then + _bhosted_name="" + fi + + [ -n "$_bhosted_name" ] || _bhosted_name="@" + + _debug "_domain" "$_domain" + _debug "_bhosted_sld" "$_bhosted_sld" + _debug "_bhosted_tld" "$_bhosted_tld" + _debug "_bhosted_name" "$_bhosted_name" + return 0 + fi + + # Auto-parse: assume last label = tld, label before = sld + # Works for .nl / .com / .org etc. + _bhosted_tld="$(printf "%s" "$domain" | awk -F. '{print $NF}')" + _bhosted_sld="$(printf "%s" "$domain" | awk -F. '{print $(NF-1)}')" + + if [ -z "$_bhosted_sld" ] || [ -z "$_bhosted_tld" ]; then + _err "Could not parse SLD/TLD from domain: $domain" + return 1 + fi + + _domain="${_bhosted_sld}.${_bhosted_tld}" + _bhosted_name="${domain%.$_domain}" + if [ "$_bhosted_name" = "$domain" ]; then + _bhosted_name="" + fi + + [ -n "$_bhosted_name" ] || _bhosted_name="@" + + _debug "_domain" "$_domain" + _debug "_bhosted_sld" "$_bhosted_sld" + _debug "_bhosted_tld" "$_bhosted_tld" + _debug "_bhosted_name" "$_bhosted_name" + + return 0 +} + +_bhosted_api_add_txt() { + _sld="$1" + _tld="$2" + _name="$3" + _content="$4" + _ttl="$5" + + _u_user="$(printf "%s" "$BHOSTED_Username" | _url_encode)" + _u_pass="$(printf "%s" "$BHOSTED_Password" | _url_encode)" + _u_sld="$(printf "%s" "$_sld" | _url_encode)" + _u_tld="$(printf "%s" "$_tld" | _url_encode)" + _u_name="$(printf "%s" "$_name" | _url_encode)" + _u_content="$(printf "%s" "$_content" | _url_encode)" + _u_ttl="$(printf "%s" "$_ttl" | _url_encode)" + + _url="${BHOSTED_API_ROOT}/addrecord?user=${_u_user}&password=${_u_pass}&tld=${_u_tld}&sld=${_u_sld}&type=TXT&name=${_u_name}&content=${_u_content}&ttl=${_u_ttl}" + + _debug "bHosted add endpoint" "${BHOSTED_API_ROOT}/addrecord" + response="$(_get "$_url")" + _ret="$?" + + _debug2 "bHosted add response" "$response" + + if [ "$_ret" != "0" ]; then + _err "bHosted addrecord request failed" + return 1 + fi + + if _bhosted_response_has_error "$response"; then + _err "bHosted addrecord returned an error" + _debug2 "response" "$response" + return 1 + fi + + return 0 +} + +_bhosted_api_del_record() { + _sld="$1" + _tld="$2" + _id="$3" + + _u_user="$(printf "%s" "$BHOSTED_Username" | _url_encode)" + _u_pass="$(printf "%s" "$BHOSTED_Password" | _url_encode)" + _u_sld="$(printf "%s" "$_sld" | _url_encode)" + _u_tld="$(printf "%s" "$_tld" | _url_encode)" + _u_id="$(printf "%s" "$_id" | _url_encode)" + + _url="${BHOSTED_API_ROOT}/delrecord?user=${_u_user}&password=${_u_pass}&tld=${_u_tld}&sld=${_u_sld}&id=${_u_id}" + + _debug "bHosted delete endpoint" "${BHOSTED_API_ROOT}/delrecord" + response="$(_get "$_url")" + _ret="$?" + + _debug2 "bHosted delete response" "$response" + + if [ "$_ret" != "0" ]; then + _err "bHosted delrecord request failed" + return 1 + fi + + if _bhosted_response_has_error "$response"; then + _err "bHosted delrecord returned an error" + _debug2 "response" "$response" + return 1 + fi + + return 0 +} + +# Extract XML tag value from response, e.g. 12345 +_bhosted_xml_value() { + _tag="$1" + _resp="$2" + + # Flatten response to simplify parsing + _flat="$(printf "%s" "$_resp" | tr -d '\r\n\t')" + printf "%s" "$_flat" | sed -n "s:.*<${_tag}>\\([^<]*\\).*:\\1:p" | head -n 1 +} + +# Return code convention: +# return 0 => response HAS error +# return 1 => response has NO error (success) +_bhosted_response_has_error() { + _resp="$1" + + # Empty response = error + if [ -z "$_resp" ]; then + _debug "Empty API response" + return 0 + fi + + # Prefer explicit bHosted XML response fields + if _contains "$_resp" ""; then + _errors="$(_bhosted_xml_value "errors" "$_resp")" + _done="$(_bhosted_xml_value "done" "$_resp")" + _subcommand="$(_bhosted_xml_value "subcommand" "$_resp")" + _id="$(_bhosted_xml_value "id" "$_resp")" + + _debug "bHosted XML subcommand" "$_subcommand" + _debug "bHosted XML id" "$_id" + _debug "bHosted XML errors" "$_errors" + _debug "bHosted XML done" "$_done" + + # Success according to provided format + if [ "$_errors" = "0" ] && [ "$_done" = "true" ]; then + return 1 + fi + + _debug "bHosted XML indicates failure" + return 0 + fi + + # Fallback for unexpected/non-XML responses + _resp_lc="$(printf "%s" "$_resp" | tr '[:upper:]' '[:lower:]')" + + if _contains "$_resp_lc" "error"; then + _debug "Detected 'error' in response" + return 0 + fi + if _contains "$_resp_lc" "fout"; then + _debug "Detected 'fout' in response" + return 0 + fi + if _contains "$_resp_lc" "invalid"; then + _debug "Detected 'invalid' in response" + return 0 + fi + if _contains "$_resp_lc" "failed"; then + _debug "Detected 'failed' in response" + return 0 + fi + if _contains "$_resp_lc" "denied"; then + _debug "Detected 'denied' in response" + return 0 + fi + + # If no explicit error markers found, assume success + return 1 +} + +# Extract record id from response +# Supports bHosted XML first, then generic fallbacks +_bhosted_extract_id() { + _resp="$1" + + # bHosted XML: 12345 + _id="$(_bhosted_xml_value "id" "$_resp" | tr -cd '0-9')" + if [ -n "$_id" ]; then + printf "%s" "$_id" + return 0 + fi + + # JSON: "id":12345 + _id="$(printf "%s" "$_resp" | _egrep_o '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -n 1 | tr -cd '0-9')" + if [ -n "$_id" ]; then + printf "%s" "$_id" + return 0 + fi + + # key=value: id=12345 + _id="$(printf "%s" "$_resp" | _egrep_o '(^|[[:space:][:punct:]])id[[:space:]]*=[[:space:]]*[0-9]+' | head -n 1 | tr -cd '0-9')" + if [ -n "$_id" ]; then + printf "%s" "$_id" + return 0 + fi + + # "record id 12345" / "recordid 12345" + _id="$(printf "%s" "$_resp" | _egrep_o '(record[[:space:]]*id|recordid)[^0-9]*[0-9]+' | head -n 1 | tr -cd '0-9')" + if [ -n "$_id" ]; then + printf "%s" "$_id" + return 0 + fi + + return 1 +} + +# Create a unique config key for cached record ids +_bhosted_cache_key() { + _fd="$1" + _tv="$2" + # md5 hex of fulldomain|txtvalue to avoid invalid chars in conf key + _hash="$(printf "%s|%s" "$_fd" "$_tv" | _digest md5 hex)" + printf "BHOSTED_RECORD_ID_%s" "$_hash" +}