add dnsapi for HestiaCP
parent
c459b5eb9d
commit
08c9b87d06
@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env sh
|
||||
# shellcheck disable=SC2034
|
||||
dns_hestiacp_info='HestiaCP DNS API
|
||||
Site: https://hestiacp.com
|
||||
Docs: https://github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_hestiacp
|
||||
|
||||
Options:
|
||||
HESTIA_HOST The HestiaCP panel URL (e.g., https://panel.domain.com:8083)
|
||||
HESTIA_ACCESS The HestiaCP API access key
|
||||
HESTIA_SECRET The HestiaCP API secret key
|
||||
HESTIA_USER Your HestiaCP username (defaults to "admin")
|
||||
|
||||
API Key Setup:
|
||||
1. Log in to HestiaCP panel as admin
|
||||
2. Go to Server -> Configure -> API
|
||||
3. Generate a key pair with "update-dns-records" permission
|
||||
4. Copy Host, Access Key, and Secret Key
|
||||
5. Login to our HestiaCP server as root, and go to /usr/local/hestia/data/api
|
||||
6. The file "update-dns-records" should contain this line in order for this script to work:
|
||||
ROLE='user'
|
||||
COMMANDS='v-list-dns-records,v-change-dns-record,v-delete-dns-record,v-add-dns-record'
|
||||
By default, only v-list-dns-records and v-change-dns-record are enabled.
|
||||
|
||||
NOTES:
|
||||
- for wildcard certificates to work, you need to use LetsEncrypt V2 provider, not Alpha ZeroSSL which is default in acme.sh
|
||||
- domains available for requesting SSL certificates will be the ones defined under your HestiaCP username (HESTIA_USER).
|
||||
|
||||
Example Usage:
|
||||
export HESTIA_HOST="https://panel.domain.com:8083"
|
||||
export HESTIA_ACCESS="your_access_key"
|
||||
export HESTIA_SECRET="your_secret_key"
|
||||
export HESTIA_USER="your_username"
|
||||
acme.sh --issue -d example.com -d *.example.com --dns dns_hestiacp
|
||||
|
||||
Author: Radu Malica <radu.malica@gmail.com> https://github.com/radumalica/
|
||||
Issues: https://github.com/acmesh-official/acme.sh/issues/6251
|
||||
'
|
||||
|
||||
######## Public functions #####################
|
||||
|
||||
# Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
|
||||
dns_hestiacp_add() {
|
||||
fulldomain=$1
|
||||
txtvalue=$2
|
||||
|
||||
HESTIA_HOST="${HESTIA_HOST:-$(_readaccountconf_mutable HESTIA_HOST)}"
|
||||
HESTIA_ACCESS="${HESTIA_ACCESS:-$(_readaccountconf_mutable HESTIA_ACCESS)}"
|
||||
HESTIA_SECRET="${HESTIA_SECRET:-$(_readaccountconf_mutable HESTIA_SECRET)}"
|
||||
HESTIA_USER="${HESTIA_USER:-$(_readaccountconf_mutable HESTIA_USER)}"
|
||||
|
||||
if [ -z "$HESTIA_HOST" ] || [ -z "$HESTIA_ACCESS" ] || [ -z "$HESTIA_SECRET" ]; then
|
||||
_err "Missing required HestiaCP credentials"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Remove trailing slash if present
|
||||
HESTIA_HOST="${HESTIA_HOST%/}"
|
||||
|
||||
# Set default user if not provided
|
||||
[ -z "$HESTIA_USER" ] && HESTIA_USER="admin"
|
||||
|
||||
# Save the credentials to the account conf file
|
||||
_saveaccountconf_mutable HESTIA_HOST "$HESTIA_HOST"
|
||||
_saveaccountconf_mutable HESTIA_ACCESS "$HESTIA_ACCESS"
|
||||
_saveaccountconf_mutable HESTIA_SECRET "$HESTIA_SECRET"
|
||||
[ "$HESTIA_USER" != "admin" ] && _saveaccountconf_mutable HESTIA_USER "$HESTIA_USER"
|
||||
|
||||
# Validate hostname format
|
||||
if ! echo "$HESTIA_HOST" | grep -qE '^https?://[^/]+$'; then
|
||||
_err "HESTIA_HOST must be a valid URL (e.g., https://panel.domain.com:8083)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate API keys are not obviously wrong
|
||||
if [ ${#HESTIA_ACCESS} -lt 20 ] || [ ${#HESTIA_SECRET} -lt 20 ]; then
|
||||
_err "HESTIA_ACCESS and HESTIA_SECRET must be valid API keys"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract domain and subdomain parts
|
||||
_debug2 "Original domain: $fulldomain"
|
||||
_domain=$(echo "$fulldomain" | sed -E 's/^[^.]+\.//' | sed -E 's/^\*\.//')
|
||||
_debug2 "Using domain: $_domain"
|
||||
|
||||
# Get existing TXT records
|
||||
_info "Getting DNS records for $_domain"
|
||||
_payload=$(_hestia_api_payload "v-list-dns-records" "$HESTIA_USER" "$_domain" "json")
|
||||
_debug2 "API payload: $_payload"
|
||||
response=$(_post "$_payload" "${HESTIA_HOST}/api/" "" "POST" "--connect-timeout 10")
|
||||
_ret=$?
|
||||
_debug3 "Raw API response: $response"
|
||||
_info "API response (ret=$_ret)"
|
||||
|
||||
# Add timeout handling
|
||||
if _contains "$response" "Operation timed out"; then
|
||||
_err "API request timed out. Please try again"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ $_ret -ne 0 ]; then
|
||||
_err "Error accessing domain: $_domain"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if _contains "$response" "Error: "; then
|
||||
_err "API error: $response"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_sub="_acme-challenge"
|
||||
|
||||
if ! _contains "$fulldomain" "$_sub"; then
|
||||
_err "Invalid domain format - missing $_sub prefix"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check for existing record with same value
|
||||
_info "Checking for existing _acme-challenge TXT records"
|
||||
found_exact=0
|
||||
while IFS=':' read -r id value || [ -n "$id" ]; do
|
||||
if [ -n "$id" ] && [ "$value" = "$txtvalue" ]; then
|
||||
_info "Found existing record with correct value, keeping it"
|
||||
found_exact=1
|
||||
break
|
||||
fi
|
||||
done <<EOF
|
||||
$(_find_dns_records "$response" "$_sub" "TXT")
|
||||
EOF
|
||||
|
||||
# If we found exact match, we're done
|
||||
if [ "$found_exact" = "1" ]; then
|
||||
_info "Using existing DNS record with correct value"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Otherwise create a new record for this challenge
|
||||
_info "Adding new TXT record for challenge"
|
||||
if ! _post "$(_hestia_api_payload "v-add-dns-record" "$HESTIA_USER" "$_domain" "$_sub" "TXT" "$txtvalue" "" "" "no" "600")" "${HESTIA_HOST}/api/" "" "POST" "--connect-timeout 10" >/dev/null 2>&1; then
|
||||
_err "Error creating new TXT record"
|
||||
return 1
|
||||
fi
|
||||
_info "Successfully added new DNS-01 challenge record"
|
||||
_debug3 "Added TXT record with value: $txtvalue"
|
||||
_info "Note: Please allow time for DNS propagation"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Usage: rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
|
||||
dns_hestiacp_rm() {
|
||||
fulldomain=$1
|
||||
txtvalue=$2
|
||||
|
||||
HESTIA_HOST="${HESTIA_HOST:-$(_readaccountconf_mutable HESTIA_HOST)}"
|
||||
HESTIA_ACCESS="${HESTIA_ACCESS:-$(_readaccountconf_mutable HESTIA_ACCESS)}"
|
||||
HESTIA_SECRET="${HESTIA_SECRET:-$(_readaccountconf_mutable HESTIA_SECRET)}"
|
||||
HESTIA_USER="${HESTIA_USER:-$(_readaccountconf_mutable HESTIA_USER)}"
|
||||
|
||||
# Remove trailing slash if present
|
||||
HESTIA_HOST="${HESTIA_HOST%/}"
|
||||
|
||||
# Set default user if not provided
|
||||
[ -z "$HESTIA_USER" ] && HESTIA_USER="admin"
|
||||
|
||||
if [ -z "$HESTIA_HOST" ] || [ -z "$HESTIA_ACCESS" ] || [ -z "$HESTIA_SECRET" ]; then
|
||||
_err "Missing required HestiaCP credentials"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate hostname format
|
||||
if ! echo "$HESTIA_HOST" | grep -qE '^https?://[^/]+$'; then
|
||||
_err "HESTIA_HOST must be a valid URL (e.g., https://panel.domain.com:8083)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate API keys are not obviously wrong
|
||||
if [ ${#HESTIA_ACCESS} -lt 20 ] || [ ${#HESTIA_SECRET} -lt 20 ]; then
|
||||
_err "HESTIA_ACCESS and HESTIA_SECRET must be valid API keys (length >= 20)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract domain parts
|
||||
_debug2 "Original domain: $fulldomain"
|
||||
|
||||
# Define subdomain constant
|
||||
_sub="_acme-challenge"
|
||||
_debug2 "Challenge prefix: $_sub"
|
||||
|
||||
# Validate _acme-challenge prefix
|
||||
if ! _contains "$fulldomain" "$_sub"; then
|
||||
_err "Invalid domain format - missing $_sub prefix"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Everything after _sub. is our domain
|
||||
_domain=$(echo "$fulldomain" | sed -E 's/^[^.]+\.//' | sed -E 's/^\*\.//')
|
||||
_debug2 "Using domain: $_domain"
|
||||
|
||||
# Get zone records
|
||||
_info "Getting DNS records for $_domain"
|
||||
response=$(_post "$(_hestia_api_payload "v-list-dns-records" "$HESTIA_USER" "$_domain" "json")" "${HESTIA_HOST}/api/" "" "POST" "--connect-timeout 10")
|
||||
_ret=$?
|
||||
_debug3 "Raw API response: $response"
|
||||
_info "API response (ret=$_ret)"
|
||||
|
||||
# Add timeout handling
|
||||
if _contains "$response" "Operation timed out"; then
|
||||
_err "API request timed out. Please try again"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Enhanced response validation
|
||||
if [ -z "$response" ]; then
|
||||
_err "Empty response received from API"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ $_ret -ne 0 ]; then
|
||||
_err "Error accessing domain: $_domain"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if _contains "$response" "Error: "; then
|
||||
_err "API error: $response"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Delete all _acme-challenge records
|
||||
_info "Removing all _acme-challenge TXT records"
|
||||
removed=0
|
||||
while IFS=':' read -r id value || [ -n "$id" ]; do
|
||||
if [ -n "$id" ]; then
|
||||
_info "Deleting challenge record $id with value: $value"
|
||||
if ! _post "$(_hestia_api_payload "v-delete-dns-record" "$HESTIA_USER" "$_domain" "$id" "no")" "${HESTIA_HOST}/api/" "" "POST" "--connect-timeout 10" >/dev/null 2>&1; then
|
||||
_err "Error deleting TXT record $id"
|
||||
return 1
|
||||
fi
|
||||
removed=$(_math "$removed" + 1)
|
||||
_debug2 "Successfully removed record $id"
|
||||
fi
|
||||
done <<EOF
|
||||
$(_find_dns_records "$response" "$_sub" "TXT")
|
||||
EOF
|
||||
|
||||
if [ $removed -eq 0 ]; then
|
||||
_info "No challenge records found to remove"
|
||||
else
|
||||
_info "Successfully removed $removed DNS-01 challenge record(s)"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
#################### Private functions below ##################################
|
||||
|
||||
# Find all record IDs and values for a given name and type
|
||||
# Args: response record_name type
|
||||
_find_dns_records() {
|
||||
_response="$1"
|
||||
_name="$2"
|
||||
_type="$3"
|
||||
|
||||
_debug2 "Parsing JSON response for '${_name}' ${_type} records"
|
||||
_debug2 "$_response"
|
||||
|
||||
# Quick validation
|
||||
if _contains "$_response" "Error: "; then
|
||||
_debug2 "Error response received: $_response"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate we have valid JSON to parse
|
||||
if ! _contains "$_response" "{"; then
|
||||
_debug2 "Not a valid JSON response: $_response"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Process JSON to find matching records
|
||||
echo "$_response" | tr -d '\n' | sed 's/},/}\n/g' | grep -o '{[^}]*"RECORD": "_acme-challenge"[^}]*}' | while read -r line; do
|
||||
id=$(echo "$line" | grep -o '"ID": "[^"]*' | cut -d'"' -f4)
|
||||
value=$(echo "$line" | grep -o '"VALUE": "[^"]*' | cut -d'"' -f4)
|
||||
echo "$id:$value"
|
||||
done
|
||||
}
|
||||
|
||||
# Build API payload
|
||||
# Args: cmd [arg1 arg2 ...]
|
||||
_hestia_api_payload() {
|
||||
_cmd=$1
|
||||
shift
|
||||
|
||||
export _H1="Content-Type: application/json"
|
||||
|
||||
# Create JSON data exactly as expected by HestiaCP
|
||||
_data="{"
|
||||
_data="$_data\"access_key\":\"$HESTIA_ACCESS\""
|
||||
_data="$_data,\"secret_key\":\"$HESTIA_SECRET\""
|
||||
_data="$_data,\"cmd\":\"$_cmd\""
|
||||
|
||||
_i=1
|
||||
for arg in "$@"; do
|
||||
_data="$_data,\"arg$_i\":\"$arg\""
|
||||
_i=$(_math $_i + 1)
|
||||
done
|
||||
|
||||
_data="$_data}"
|
||||
printf "%s" "$_data"
|
||||
}
|
Loading…
Reference in New Issue