From bfd1f9bf6ce1a6aead0a711b7400e773010bed36 Mon Sep 17 00:00:00 2001 From: Kilian Ries Date: Thu, 22 Jan 2026 16:43:26 +0100 Subject: [PATCH 01/10] [CLOUD-31] add acme.sh opusdns provider --- dnsapi/dns_opusdns.sh | 379 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100755 dnsapi/dns_opusdns.sh diff --git a/dnsapi/dns_opusdns.sh b/dnsapi/dns_opusdns.sh new file mode 100755 index 00000000..cf088a07 --- /dev/null +++ b/dnsapi/dns_opusdns.sh @@ -0,0 +1,379 @@ +#!/usr/bin/env sh + +# shellcheck disable=SC2034 +dns_opusdns_info='OpusDNS.com +Site: OpusDNS.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_opusdns +Options: + OPUSDNS_API_Key API Key. Can be created at https://dashboard.opusdns.com/settings/api-keys + OPUSDNS_API_Endpoint API Endpoint URL. Default "https://api.opusdns.com". Optional. + OPUSDNS_TTL TTL for DNS challenge records in seconds. Default "60". Optional. + OPUSDNS_Polling_Interval DNS propagation check interval in seconds. Default "6". Optional. + OPUSDNS_Propagation_Timeout Maximum time to wait for DNS propagation in seconds. Default "120". Optional. +Issues: github.com/acmesh-official/acme.sh/issues/XXXX +Author: OpusDNS Team +' + +OPUSDNS_API_Endpoint_Default="https://api.opusdns.com" +OPUSDNS_TTL_Default=60 +OPUSDNS_Polling_Interval_Default=6 +OPUSDNS_Propagation_Timeout_Default=120 + +######## Public functions ########### + +# Add DNS TXT record +# Usage: dns_opusdns_add _acme-challenge.example.com "token_value" +dns_opusdns_add() { + fulldomain=$1 + txtvalue=$2 + + _info "Using OpusDNS DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + # Load and validate credentials + OPUSDNS_API_Key="${OPUSDNS_API_Key:-$(_readaccountconf_mutable OPUSDNS_API_Key)}" + if [ -z "$OPUSDNS_API_Key" ]; then + _err "OPUSDNS_API_Key not set. Please set it and try again." + _err "You can create an API key at your OpusDNS dashboard." + return 1 + fi + + # Save credentials for future use + _saveaccountconf_mutable OPUSDNS_API_Key "$OPUSDNS_API_Key" + + # Load optional configuration + OPUSDNS_API_Endpoint="${OPUSDNS_API_Endpoint:-$(_readaccountconf_mutable OPUSDNS_API_Endpoint)}" + if [ -z "$OPUSDNS_API_Endpoint" ]; then + OPUSDNS_API_Endpoint="$OPUSDNS_API_Endpoint_Default" + fi + _saveaccountconf_mutable OPUSDNS_API_Endpoint "$OPUSDNS_API_Endpoint" + + OPUSDNS_TTL="${OPUSDNS_TTL:-$(_readaccountconf_mutable OPUSDNS_TTL)}" + if [ -z "$OPUSDNS_TTL" ]; then + OPUSDNS_TTL="$OPUSDNS_TTL_Default" + fi + _saveaccountconf_mutable OPUSDNS_TTL "$OPUSDNS_TTL" + + OPUSDNS_Polling_Interval="${OPUSDNS_Polling_Interval:-$OPUSDNS_Polling_Interval_Default}" + OPUSDNS_Propagation_Timeout="${OPUSDNS_Propagation_Timeout:-$OPUSDNS_Propagation_Timeout_Default}" + + _debug "API Endpoint: $OPUSDNS_API_Endpoint" + _debug "TTL: $OPUSDNS_TTL" + + # Detect zone from FQDN + if ! _get_zone "$fulldomain"; then + _err "Failed to detect zone for domain: $fulldomain" + return 1 + fi + + _info "Detected zone: $_zone" + _debug "Record name: $_record_name" + + # Add the TXT record + if ! _opusdns_add_record "$_zone" "$_record_name" "$txtvalue"; then + _err "Failed to add TXT record" + return 1 + fi + + _info "TXT record added successfully" + + # Wait for DNS propagation + if ! _opusdns_wait_for_propagation "$fulldomain" "$txtvalue"; then + _err "Warning: DNS record may not have propagated yet" + _err "Certificate issuance may fail. Please check your DNS configuration." + # Don't fail here - let ACME client decide + fi + + return 0 +} + +# Remove DNS TXT record +# Usage: dns_opusdns_rm _acme-challenge.example.com "token_value" +dns_opusdns_rm() { + fulldomain=$1 + txtvalue=$2 + + _info "Removing OpusDNS DNS record" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + # Load credentials + OPUSDNS_API_Key="${OPUSDNS_API_Key:-$(_readaccountconf_mutable OPUSDNS_API_Key)}" + OPUSDNS_API_Endpoint="${OPUSDNS_API_Endpoint:-$(_readaccountconf_mutable OPUSDNS_API_Endpoint)}" + OPUSDNS_TTL="${OPUSDNS_TTL:-$(_readaccountconf_mutable OPUSDNS_TTL)}" + + if [ -z "$OPUSDNS_API_Endpoint" ]; then + OPUSDNS_API_Endpoint="$OPUSDNS_API_Endpoint_Default" + fi + + if [ -z "$OPUSDNS_TTL" ]; then + OPUSDNS_TTL="$OPUSDNS_TTL_Default" + fi + + if [ -z "$OPUSDNS_API_Key" ]; then + _err "OPUSDNS_API_Key not found" + return 1 + fi + + # Detect zone from FQDN + if ! _get_zone "$fulldomain"; then + _err "Failed to detect zone for domain: $fulldomain" + # Don't fail cleanup - best effort + return 0 + fi + + _info "Detected zone: $_zone" + _debug "Record name: $_record_name" + + # Remove the TXT record (need to pass txtvalue) + if ! _opusdns_remove_record "$_zone" "$_record_name" "$txtvalue"; then + _err "Warning: Failed to remove TXT record (this is usually not critical)" + # Don't fail cleanup - best effort + return 0 + fi + + _info "TXT record removed successfully" + return 0 +} + +######## Private functions ########### + +# Detect zone from FQDN by querying OpusDNS API +# Sets global variables: _zone, _record_name +_get_zone() { + domain=$1 + _debug "Detecting zone for: $domain" + + # Remove trailing dot if present + domain=$(echo "$domain" | sed 's/\.$//') + + # Get all zones from OpusDNS with pagination support + export _H1="X-Api-Key: $OPUSDNS_API_Key" + + zones="" + page=1 + has_more=1 + + while [ $has_more -eq 1 ]; do + _debug2 "Fetching zones page $page" + response=$(_get "$OPUSDNS_API_Endpoint/v1/dns?page=$page&page_size=100") + if [ $? -ne 0 ]; then + _err "Failed to query zones from OpusDNS API (page $page)" + _debug "Response: $response" + return 1 + fi + + _debug2 "Zones response (page $page): $response" + + # Extract zone names from this page (try jq first, fallback to grep/sed) + if _exists jq; then + page_zones=$(echo "$response" | jq -r '.results[].name' 2>/dev/null | sed 's/\.$//') + has_next=$(echo "$response" | jq -r '.has_next_page // false' 2>/dev/null) + else + # Fallback: extract zone names using grep/sed + # Note: This simple parser does not handle escaped quotes in zone names. + # Zone names with escaped quotes are extremely rare and would require jq. + page_zones=$(echo "$response" | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"\\]*"' | sed 's/"name"[[:space:]]*:[[:space:]]*"\([^"\\]*\)"/\1/' | sed 's/\.$//') + has_next=$(echo "$response" | grep -oE '"has_next_page"[[:space:]]*:[[:space:]]*(true|false)' | grep -o 'true\|false') + fi + + # Append zones from this page + if [ -n "$page_zones" ]; then + if [ -z "$zones" ]; then + zones="$page_zones" + else + zones="$zones +$page_zones" + fi + fi + + # Check if there are more pages + if [ "$has_next" = "true" ]; then + page=$((page + 1)) + else + has_more=0 + fi + done + + if [ -z "$zones" ]; then + _err "No zones found in OpusDNS account" + _debug "API Response: $response" + return 1 + fi + + _debug2 "Available zones (all pages): $zones" + + # Find longest matching zone + _zone="" + _zone_length=0 + + for zone in $zones; do + zone_with_dot="${zone}." + if _endswith "$domain." "$zone_with_dot"; then + zone_length=${#zone} + if [ $zone_length -gt $_zone_length ]; then + _zone="$zone" + _zone_length=$zone_length + fi + fi + done + + if [ -z "$_zone" ]; then + _err "No matching zone found for domain: $domain" + _err "Available zones: $zones" + return 1 + fi + + # Calculate record name (subdomain part) + # Use parameter expansion instead of sed to avoid regex metacharacter issues + _record_name="${domain%.${_zone}}" + # Handle case where domain equals zone (remove trailing dot if present) + if [ "$_record_name" = "$domain" ]; then + _record_name="${domain%${_zone}}" + _record_name="${_record_name%.}" + fi + + if [ -z "$_record_name" ]; then + _record_name="@" + fi + + return 0 +} + +# Add TXT record using OpusDNS API +_opusdns_add_record() { + zone=$1 + record_name=$2 + txtvalue=$3 + + _debug "Adding TXT record: $record_name.$zone = $txtvalue" + + # Escape all JSON special characters in txtvalue + # Order matters: escape backslashes first, then other characters + escaped_value=$(printf '%s' "$txtvalue" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' | sed ':a;N;$!ba;s/\n/\\n/g') + + # Build JSON payload + # Note: TXT records need quotes around the value in rdata + json_payload="{\"ops\":[{\"op\":\"upsert\",\"record\":{\"name\":\"$record_name\",\"type\":\"TXT\",\"ttl\":$OPUSDNS_TTL,\"rdata\":\"\\\"$escaped_value\\\"\"}}]}" + + _debug2 "JSON payload: $json_payload" + + # Send PATCH request + export _H1="X-Api-Key: $OPUSDNS_API_Key" + export _H2="Content-Type: application/json" + + response=$(_post "$json_payload" "$OPUSDNS_API_Endpoint/v1/dns/$zone/records" "" "PATCH") + status=$? + + _debug2 "API Response: $response" + + if [ $status -ne 0 ]; then + _err "Failed to add TXT record" + _err "API Response: $response" + return 1 + fi + + # Check for error in response (OpusDNS returns JSON error even on failure) + # Use anchored pattern to avoid matching field names like "error_count" + if echo "$response" | grep -q '"error":'; then + _err "API returned error: $response" + return 1 + fi + + return 0 +} + +# Remove TXT record using OpusDNS API +_opusdns_remove_record() { + zone=$1 + record_name=$2 + txtvalue=$3 + + _debug "Removing TXT record: $record_name.$zone = $txtvalue" + + # Escape all JSON special characters in txtvalue (same as add) + escaped_value=$(printf '%s' "$txtvalue" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' | sed ':a;N;$!ba;s/\n/\\n/g') + + # Build JSON payload for removal - needs complete record specification + json_payload="{\"ops\":[{\"op\":\"remove\",\"record\":{\"name\":\"$record_name\",\"type\":\"TXT\",\"ttl\":$OPUSDNS_TTL,\"rdata\":\"\\\"$escaped_value\\\"\"}}]}" + + _debug2 "JSON payload: $json_payload" + + # Send PATCH request + export _H1="X-Api-Key: $OPUSDNS_API_Key" + export _H2="Content-Type: application/json" + + response=$(_post "$json_payload" "$OPUSDNS_API_Endpoint/v1/dns/$zone/records" "" "PATCH") + status=$? + + _debug2 "API Response: $response" + + if [ $status -ne 0 ]; then + _err "Failed to remove TXT record" + _err "API Response: $response" + return 1 + fi + + return 0 +} + +# Wait for DNS propagation by checking OpusDNS authoritative nameservers +_opusdns_wait_for_propagation() { + fulldomain=$1 + txtvalue=$2 + + _info "Waiting for DNS propagation to authoritative nameservers (max ${OPUSDNS_Propagation_Timeout}s)..." + + max_attempts=$((OPUSDNS_Propagation_Timeout / OPUSDNS_Polling_Interval)) + # Ensure at least one attempt even if interval > timeout + if [ "$max_attempts" -lt 1 ]; then + max_attempts=1 + fi + attempt=1 + + # OpusDNS authoritative nameservers + nameservers="ns1.opusdns.com ns2.opusdns.net" + + while [ $attempt -le $max_attempts ]; do + _debug "Propagation check attempt $attempt/$max_attempts" + + all_propagated=1 + + # Check all OpusDNS authoritative nameservers + for ns in $nameservers; do + if _exists dig; then + result=$(dig @$ns +short "$fulldomain" TXT 2>/dev/null | tr -d '"') + elif _exists nslookup; then + result=$(nslookup -type=TXT "$fulldomain" $ns 2>/dev/null | grep -A1 "text =" | tail -n1 | tr -d '"' | sed 's/^[[:space:]]*//') + else + _err "Neither dig nor nslookup found. Cannot verify DNS propagation." + return 1 + fi + + _debug2 "DNS query result from $ns: $result" + + if ! echo "$result" | grep -qF "$txtvalue"; then + _debug "Record not yet on $ns" + all_propagated=0 + else + _debug "Record found on $ns ✓" + fi + done + + if [ $all_propagated -eq 1 ]; then + _info "DNS record propagated to all OpusDNS nameservers!" + return 0 + fi + + if [ $attempt -lt $max_attempts ]; then + _debug "Record not propagated to all nameservers yet, waiting ${OPUSDNS_Polling_Interval}s..." + sleep "$OPUSDNS_Polling_Interval" + fi + + attempt=$((attempt + 1)) + done + + _err "DNS record did not propagate to all nameservers within ${OPUSDNS_Propagation_Timeout} seconds" + return 1 +} From 01c93b9bbd0b76488c8201876bb20ccbbfbb7cc3 Mon Sep 17 00:00:00 2001 From: Kilian Ries Date: Thu, 22 Jan 2026 17:04:43 +0100 Subject: [PATCH 03/10] Fix shellcheck and shfmt issues - Add double quotes around variables to prevent globbing - Fix parameter expansion quoting in ${domain%.${_zone}} - Remove trailing whitespace for shfmt compliance --- dnsapi/dns_opusdns.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dnsapi/dns_opusdns.sh b/dnsapi/dns_opusdns.sh index cf088a07..feb5507e 100755 --- a/dnsapi/dns_opusdns.sh +++ b/dnsapi/dns_opusdns.sh @@ -102,7 +102,7 @@ dns_opusdns_rm() { OPUSDNS_API_Key="${OPUSDNS_API_Key:-$(_readaccountconf_mutable OPUSDNS_API_Key)}" OPUSDNS_API_Endpoint="${OPUSDNS_API_Endpoint:-$(_readaccountconf_mutable OPUSDNS_API_Endpoint)}" OPUSDNS_TTL="${OPUSDNS_TTL:-$(_readaccountconf_mutable OPUSDNS_TTL)}" - + if [ -z "$OPUSDNS_API_Endpoint" ]; then OPUSDNS_API_Endpoint="$OPUSDNS_API_Endpoint_Default" fi @@ -150,11 +150,11 @@ _get_zone() { # Get all zones from OpusDNS with pagination support export _H1="X-Api-Key: $OPUSDNS_API_Key" - + zones="" page=1 has_more=1 - + while [ $has_more -eq 1 ]; do _debug2 "Fetching zones page $page" response=$(_get "$OPUSDNS_API_Endpoint/v1/dns?page=$page&page_size=100") @@ -212,7 +212,7 @@ $page_zones" zone_with_dot="${zone}." if _endswith "$domain." "$zone_with_dot"; then zone_length=${#zone} - if [ $zone_length -gt $_zone_length ]; then + if [ "$zone_length" -gt "$_zone_length" ]; then _zone="$zone" _zone_length=$zone_length fi @@ -227,10 +227,10 @@ $page_zones" # Calculate record name (subdomain part) # Use parameter expansion instead of sed to avoid regex metacharacter issues - _record_name="${domain%.${_zone}}" + _record_name="${domain%."${_zone}"}" # Handle case where domain equals zone (remove trailing dot if present) if [ "$_record_name" = "$domain" ]; then - _record_name="${domain%${_zone}}" + _record_name="${domain%"${_zone}"}" _record_name="${_record_name%.}" fi @@ -343,9 +343,9 @@ _opusdns_wait_for_propagation() { # Check all OpusDNS authoritative nameservers for ns in $nameservers; do if _exists dig; then - result=$(dig @$ns +short "$fulldomain" TXT 2>/dev/null | tr -d '"') + result=$(dig @"$ns" +short "$fulldomain" TXT 2>/dev/null | tr -d '"') elif _exists nslookup; then - result=$(nslookup -type=TXT "$fulldomain" $ns 2>/dev/null | grep -A1 "text =" | tail -n1 | tr -d '"' | sed 's/^[[:space:]]*//') + result=$(nslookup -type=TXT "$fulldomain" "$ns" 2>/dev/null | grep -A1 "text =" | tail -n1 | tr -d '"' | sed 's/^[[:space:]]*//') else _err "Neither dig nor nslookup found. Cannot verify DNS propagation." return 1 From dc65223da1c99dcd7351f7c4d6e9c410f9ff7386 Mon Sep 17 00:00:00 2001 From: Kilian Ries Date: Thu, 22 Jan 2026 17:10:12 +0100 Subject: [PATCH 04/10] Remove all trailing whitespace for shfmt compliance --- dnsapi/dns_opusdns.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dnsapi/dns_opusdns.sh b/dnsapi/dns_opusdns.sh index feb5507e..a57d2188 100755 --- a/dnsapi/dns_opusdns.sh +++ b/dnsapi/dns_opusdns.sh @@ -177,7 +177,7 @@ _get_zone() { page_zones=$(echo "$response" | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"\\]*"' | sed 's/"name"[[:space:]]*:[[:space:]]*"\([^"\\]*\)"/\1/' | sed 's/\.$//') has_next=$(echo "$response" | grep -oE '"has_next_page"[[:space:]]*:[[:space:]]*(true|false)' | grep -o 'true\|false') fi - + # Append zones from this page if [ -n "$page_zones" ]; then if [ -z "$zones" ]; then @@ -187,7 +187,7 @@ _get_zone() { $page_zones" fi fi - + # Check if there are more pages if [ "$has_next" = "true" ]; then page=$((page + 1)) @@ -233,7 +233,7 @@ $page_zones" _record_name="${domain%"${_zone}"}" _record_name="${_record_name%.}" fi - + if [ -z "$_record_name" ]; then _record_name="@" fi @@ -339,7 +339,7 @@ _opusdns_wait_for_propagation() { _debug "Propagation check attempt $attempt/$max_attempts" all_propagated=1 - + # Check all OpusDNS authoritative nameservers for ns in $nameservers; do if _exists dig; then From 30c9332327b2c3f5a2a5aacf7bc5389b76cc126d Mon Sep 17 00:00:00 2001 From: Kilian Ries Date: Thu, 22 Jan 2026 17:28:41 +0100 Subject: [PATCH 05/10] Remove custom DNS propagation check acme.sh handles DNS propagation checking internally via --dnssleep and _check_dns_record. Custom propagation checks are unnecessary and can conflict with acme.sh's own timing. Removed: - _opusdns_wait_for_propagation() function - OPUSDNS_Polling_Interval option - OPUSDNS_Propagation_Timeout option Script is now consistent with other DNS API implementations (Cloudflare, AWS, etc.) which don't have custom propagation checks. --- dnsapi/dns_opusdns.sh | 75 ------------------------------------------- 1 file changed, 75 deletions(-) diff --git a/dnsapi/dns_opusdns.sh b/dnsapi/dns_opusdns.sh index a57d2188..6c23a904 100755 --- a/dnsapi/dns_opusdns.sh +++ b/dnsapi/dns_opusdns.sh @@ -8,16 +8,12 @@ Options: OPUSDNS_API_Key API Key. Can be created at https://dashboard.opusdns.com/settings/api-keys OPUSDNS_API_Endpoint API Endpoint URL. Default "https://api.opusdns.com". Optional. OPUSDNS_TTL TTL for DNS challenge records in seconds. Default "60". Optional. - OPUSDNS_Polling_Interval DNS propagation check interval in seconds. Default "6". Optional. - OPUSDNS_Propagation_Timeout Maximum time to wait for DNS propagation in seconds. Default "120". Optional. Issues: github.com/acmesh-official/acme.sh/issues/XXXX Author: OpusDNS Team ' OPUSDNS_API_Endpoint_Default="https://api.opusdns.com" OPUSDNS_TTL_Default=60 -OPUSDNS_Polling_Interval_Default=6 -OPUSDNS_Propagation_Timeout_Default=120 ######## Public functions ########### @@ -55,9 +51,6 @@ dns_opusdns_add() { fi _saveaccountconf_mutable OPUSDNS_TTL "$OPUSDNS_TTL" - OPUSDNS_Polling_Interval="${OPUSDNS_Polling_Interval:-$OPUSDNS_Polling_Interval_Default}" - OPUSDNS_Propagation_Timeout="${OPUSDNS_Propagation_Timeout:-$OPUSDNS_Propagation_Timeout_Default}" - _debug "API Endpoint: $OPUSDNS_API_Endpoint" _debug "TTL: $OPUSDNS_TTL" @@ -77,14 +70,6 @@ dns_opusdns_add() { fi _info "TXT record added successfully" - - # Wait for DNS propagation - if ! _opusdns_wait_for_propagation "$fulldomain" "$txtvalue"; then - _err "Warning: DNS record may not have propagated yet" - _err "Certificate issuance may fail. Please check your DNS configuration." - # Don't fail here - let ACME client decide - fi - return 0 } @@ -317,63 +302,3 @@ _opusdns_remove_record() { return 0 } - -# Wait for DNS propagation by checking OpusDNS authoritative nameservers -_opusdns_wait_for_propagation() { - fulldomain=$1 - txtvalue=$2 - - _info "Waiting for DNS propagation to authoritative nameservers (max ${OPUSDNS_Propagation_Timeout}s)..." - - max_attempts=$((OPUSDNS_Propagation_Timeout / OPUSDNS_Polling_Interval)) - # Ensure at least one attempt even if interval > timeout - if [ "$max_attempts" -lt 1 ]; then - max_attempts=1 - fi - attempt=1 - - # OpusDNS authoritative nameservers - nameservers="ns1.opusdns.com ns2.opusdns.net" - - while [ $attempt -le $max_attempts ]; do - _debug "Propagation check attempt $attempt/$max_attempts" - - all_propagated=1 - - # Check all OpusDNS authoritative nameservers - for ns in $nameservers; do - if _exists dig; then - result=$(dig @"$ns" +short "$fulldomain" TXT 2>/dev/null | tr -d '"') - elif _exists nslookup; then - result=$(nslookup -type=TXT "$fulldomain" "$ns" 2>/dev/null | grep -A1 "text =" | tail -n1 | tr -d '"' | sed 's/^[[:space:]]*//') - else - _err "Neither dig nor nslookup found. Cannot verify DNS propagation." - return 1 - fi - - _debug2 "DNS query result from $ns: $result" - - if ! echo "$result" | grep -qF "$txtvalue"; then - _debug "Record not yet on $ns" - all_propagated=0 - else - _debug "Record found on $ns ✓" - fi - done - - if [ $all_propagated -eq 1 ]; then - _info "DNS record propagated to all OpusDNS nameservers!" - return 0 - fi - - if [ $attempt -lt $max_attempts ]; then - _debug "Record not propagated to all nameservers yet, waiting ${OPUSDNS_Polling_Interval}s..." - sleep "$OPUSDNS_Polling_Interval" - fi - - attempt=$((attempt + 1)) - done - - _err "DNS record did not propagate to all nameservers within ${OPUSDNS_Propagation_Timeout} seconds" - return 1 -} From 25a3ee48df9c6af194bcc2c0dbd65b441481b0cb Mon Sep 17 00:00:00 2001 From: Kilian Ries Date: Thu, 22 Jan 2026 17:58:09 +0100 Subject: [PATCH 06/10] Fix zone pagination parsing - Fixed jq path: .pagination.has_next_page instead of .has_next_page - Fixed grep fallback: remove rrsets before extracting zone names to avoid matching nested 'name' fields - Simplified has_next_page detection with simple grep -q - Added debug output for page zones and has_next status --- dnsapi/dns_opusdns.sh | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/dnsapi/dns_opusdns.sh b/dnsapi/dns_opusdns.sh index 6c23a904..f6bd507e 100755 --- a/dnsapi/dns_opusdns.sh +++ b/dnsapi/dns_opusdns.sh @@ -151,18 +151,27 @@ _get_zone() { _debug2 "Zones response (page $page): $response" - # Extract zone names from this page (try jq first, fallback to grep/sed) + # Extract zone names from this page + # The API returns: {"results":[{"name":"zone.com.",...},...],"pagination":{"has_next_page":true,...}} if _exists jq; then page_zones=$(echo "$response" | jq -r '.results[].name' 2>/dev/null | sed 's/\.$//') - has_next=$(echo "$response" | jq -r '.has_next_page // false' 2>/dev/null) + has_next=$(echo "$response" | jq -r '.pagination.has_next_page // false' 2>/dev/null) else # Fallback: extract zone names using grep/sed - # Note: This simple parser does not handle escaped quotes in zone names. - # Zone names with escaped quotes are extremely rare and would require jq. - page_zones=$(echo "$response" | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"\\]*"' | sed 's/"name"[[:space:]]*:[[:space:]]*"\([^"\\]*\)"/\1/' | sed 's/\.$//') - has_next=$(echo "$response" | grep -oE '"has_next_page"[[:space:]]*:[[:space:]]*(true|false)' | grep -o 'true\|false') + # Extract only top-level zone names from results array (before rrsets) + # Pattern: "results":[{"...","name":"zonename.com.","domain_parts": + page_zones=$(echo "$response" | sed 's/,"rrsets":\[[^]]*\]//g' | grep -o '"results":\[.*\]' | grep -o '"name":"[^"]*"' | sed 's/"name":"//g;s/"//g;s/\.$//') + # Extract has_next_page from pagination object + if echo "$response" | grep -q '"has_next_page":true'; then + has_next="true" + else + has_next="false" + fi fi + _debug2 "Page $page zones: $page_zones" + _debug2 "Has next page: $has_next" + # Append zones from this page if [ -n "$page_zones" ]; then if [ -z "$zones" ]; then From 163eb1acb9a7f1170e407d14358599a563dbd68f Mon Sep 17 00:00:00 2001 From: Kilian Ries Date: Thu, 22 Jan 2026 18:11:01 +0100 Subject: [PATCH 07/10] Simplify zone detection with API check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of fetching all zones and matching, iterate through domain parts and check each against the API until a valid zone is found. Same approach as GoDaddy DNS plugin. Example: _acme-challenge.test.example.com - Try: test.example.com → 404 - Try: example.com → 200 ✓ → zone found! Script reduced from 304 to 255 lines. --- dnsapi/dns_opusdns.sh | 112 ++++++++++-------------------------------- 1 file changed, 27 insertions(+), 85 deletions(-) diff --git a/dnsapi/dns_opusdns.sh b/dnsapi/dns_opusdns.sh index f6bd507e..b9337b89 100755 --- a/dnsapi/dns_opusdns.sh +++ b/dnsapi/dns_opusdns.sh @@ -124,7 +124,8 @@ dns_opusdns_rm() { ######## Private functions ########### -# Detect zone from FQDN by querying OpusDNS API +# Detect zone from FQDN by checking against OpusDNS API +# Iterates through domain parts until a valid zone is found # Sets global variables: _zone, _record_name _get_zone() { domain=$1 @@ -133,100 +134,41 @@ _get_zone() { # Remove trailing dot if present domain=$(echo "$domain" | sed 's/\.$//') - # Get all zones from OpusDNS with pagination support export _H1="X-Api-Key: $OPUSDNS_API_Key" - zones="" - page=1 - has_more=1 - - while [ $has_more -eq 1 ]; do - _debug2 "Fetching zones page $page" - response=$(_get "$OPUSDNS_API_Endpoint/v1/dns?page=$page&page_size=100") - if [ $? -ne 0 ]; then - _err "Failed to query zones from OpusDNS API (page $page)" - _debug "Response: $response" + # Start from position 2 (skip first part like _acme-challenge) + i=2 + p=1 + while true; do + # Extract potential zone (domain parts from position i onwards) + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug "Trying zone: $h" + + if [ -z "$h" ]; then + # No more parts to try + _err "Could not find a valid zone for: $domain" return 1 fi - _debug2 "Zones response (page $page): $response" - - # Extract zone names from this page - # The API returns: {"results":[{"name":"zone.com.",...},...],"pagination":{"has_next_page":true,...}} - if _exists jq; then - page_zones=$(echo "$response" | jq -r '.results[].name' 2>/dev/null | sed 's/\.$//') - has_next=$(echo "$response" | jq -r '.pagination.has_next_page // false' 2>/dev/null) - else - # Fallback: extract zone names using grep/sed - # Extract only top-level zone names from results array (before rrsets) - # Pattern: "results":[{"...","name":"zonename.com.","domain_parts": - page_zones=$(echo "$response" | sed 's/,"rrsets":\[[^]]*\]//g' | grep -o '"results":\[.*\]' | grep -o '"name":"[^"]*"' | sed 's/"name":"//g;s/"//g;s/\.$//') - # Extract has_next_page from pagination object - if echo "$response" | grep -q '"has_next_page":true'; then - has_next="true" - else - has_next="false" - fi - fi - - _debug2 "Page $page zones: $page_zones" - _debug2 "Has next page: $has_next" - - # Append zones from this page - if [ -n "$page_zones" ]; then - if [ -z "$zones" ]; then - zones="$page_zones" - else - zones="$zones -$page_zones" - fi - fi + # Check if this zone exists in OpusDNS + response=$(_get "$OPUSDNS_API_Endpoint/v1/dns/$h") - # Check if there are more pages - if [ "$has_next" = "true" ]; then - page=$((page + 1)) - else - has_more=0 + if _contains "$response" '"name"'; then + # Zone found + _record_name=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _zone="$h" + _debug "Found zone: $_zone" + _debug "Record name: $_record_name" + return 0 fi - done - - if [ -z "$zones" ]; then - _err "No zones found in OpusDNS account" - _debug "API Response: $response" - return 1 - fi - _debug2 "Available zones (all pages): $zones" - - # Find longest matching zone - _zone="" - _zone_length=0 - - for zone in $zones; do - zone_with_dot="${zone}." - if _endswith "$domain." "$zone_with_dot"; then - zone_length=${#zone} - if [ "$zone_length" -gt "$_zone_length" ]; then - _zone="$zone" - _zone_length=$zone_length - fi - fi + _debug "$h not found, trying next" + p="$i" + i=$(_math "$i" + 1) done - if [ -z "$_zone" ]; then - _err "No matching zone found for domain: $domain" - _err "Available zones: $zones" - return 1 - fi - - # Calculate record name (subdomain part) - # Use parameter expansion instead of sed to avoid regex metacharacter issues - _record_name="${domain%."${_zone}"}" - # Handle case where domain equals zone (remove trailing dot if present) - if [ "$_record_name" = "$domain" ]; then - _record_name="${domain%"${_zone}"}" - _record_name="${_record_name%.}" - fi + return 1 +} if [ -z "$_record_name" ]; then _record_name="@" From 9e584e346debc9f8d008ab5bcf11489b8926c22c Mon Sep 17 00:00:00 2001 From: Kilian Ries Date: Thu, 22 Jan 2026 18:13:09 +0100 Subject: [PATCH 08/10] Refactor: Add _opusdns_api helper, simplify code - Added _opusdns_api() for all API requests - Added _opusdns_init() for config initialization - Removed duplicate code in add/rm functions - Removed dead code (orphaned lines 173-178) - Script now 158 lines (was 255, originally 379) --- dnsapi/dns_opusdns.sh | 223 ++++++++++++------------------------------ 1 file changed, 63 insertions(+), 160 deletions(-) diff --git a/dnsapi/dns_opusdns.sh b/dnsapi/dns_opusdns.sh index b9337b89..19205256 100755 --- a/dnsapi/dns_opusdns.sh +++ b/dnsapi/dns_opusdns.sh @@ -18,7 +18,6 @@ OPUSDNS_TTL_Default=60 ######## Public functions ########### # Add DNS TXT record -# Usage: dns_opusdns_add _acme-challenge.example.com "token_value" dns_opusdns_add() { fulldomain=$1 txtvalue=$2 @@ -27,44 +26,17 @@ dns_opusdns_add() { _debug fulldomain "$fulldomain" _debug txtvalue "$txtvalue" - # Load and validate credentials - OPUSDNS_API_Key="${OPUSDNS_API_Key:-$(_readaccountconf_mutable OPUSDNS_API_Key)}" - if [ -z "$OPUSDNS_API_Key" ]; then - _err "OPUSDNS_API_Key not set. Please set it and try again." - _err "You can create an API key at your OpusDNS dashboard." + if ! _opusdns_init; then return 1 fi - # Save credentials for future use - _saveaccountconf_mutable OPUSDNS_API_Key "$OPUSDNS_API_Key" - - # Load optional configuration - OPUSDNS_API_Endpoint="${OPUSDNS_API_Endpoint:-$(_readaccountconf_mutable OPUSDNS_API_Endpoint)}" - if [ -z "$OPUSDNS_API_Endpoint" ]; then - OPUSDNS_API_Endpoint="$OPUSDNS_API_Endpoint_Default" - fi - _saveaccountconf_mutable OPUSDNS_API_Endpoint "$OPUSDNS_API_Endpoint" - - OPUSDNS_TTL="${OPUSDNS_TTL:-$(_readaccountconf_mutable OPUSDNS_TTL)}" - if [ -z "$OPUSDNS_TTL" ]; then - OPUSDNS_TTL="$OPUSDNS_TTL_Default" - fi - _saveaccountconf_mutable OPUSDNS_TTL "$OPUSDNS_TTL" - - _debug "API Endpoint: $OPUSDNS_API_Endpoint" - _debug "TTL: $OPUSDNS_TTL" - - # Detect zone from FQDN if ! _get_zone "$fulldomain"; then - _err "Failed to detect zone for domain: $fulldomain" return 1 fi - _info "Detected zone: $_zone" - _debug "Record name: $_record_name" + _info "Zone: $_zone, Record: $_record_name" - # Add the TXT record - if ! _opusdns_add_record "$_zone" "$_record_name" "$txtvalue"; then + if ! _opusdns_api PATCH "/v1/dns/$_zone/records" "{\"ops\":[{\"op\":\"upsert\",\"record\":{\"name\":\"$_record_name\",\"type\":\"TXT\",\"ttl\":$OPUSDNS_TTL,\"rdata\":\"\\\"$txtvalue\\\"\"}}]}"; then _err "Failed to add TXT record" return 1 fi @@ -74,7 +46,6 @@ dns_opusdns_add() { } # Remove DNS TXT record -# Usage: dns_opusdns_rm _acme-challenge.example.com "token_value" dns_opusdns_rm() { fulldomain=$1 txtvalue=$2 @@ -83,38 +54,19 @@ dns_opusdns_rm() { _debug fulldomain "$fulldomain" _debug txtvalue "$txtvalue" - # Load credentials - OPUSDNS_API_Key="${OPUSDNS_API_Key:-$(_readaccountconf_mutable OPUSDNS_API_Key)}" - OPUSDNS_API_Endpoint="${OPUSDNS_API_Endpoint:-$(_readaccountconf_mutable OPUSDNS_API_Endpoint)}" - OPUSDNS_TTL="${OPUSDNS_TTL:-$(_readaccountconf_mutable OPUSDNS_TTL)}" - - if [ -z "$OPUSDNS_API_Endpoint" ]; then - OPUSDNS_API_Endpoint="$OPUSDNS_API_Endpoint_Default" - fi - - if [ -z "$OPUSDNS_TTL" ]; then - OPUSDNS_TTL="$OPUSDNS_TTL_Default" - fi - - if [ -z "$OPUSDNS_API_Key" ]; then - _err "OPUSDNS_API_Key not found" + if ! _opusdns_init; then return 1 fi - # Detect zone from FQDN if ! _get_zone "$fulldomain"; then - _err "Failed to detect zone for domain: $fulldomain" - # Don't fail cleanup - best effort + _err "Zone not found, cleanup skipped" return 0 fi - _info "Detected zone: $_zone" - _debug "Record name: $_record_name" + _info "Zone: $_zone, Record: $_record_name" - # Remove the TXT record (need to pass txtvalue) - if ! _opusdns_remove_record "$_zone" "$_record_name" "$txtvalue"; then - _err "Warning: Failed to remove TXT record (this is usually not critical)" - # Don't fail cleanup - best effort + if ! _opusdns_api PATCH "/v1/dns/$_zone/records" "{\"ops\":[{\"op\":\"remove\",\"record\":{\"name\":\"$_record_name\",\"type\":\"TXT\",\"ttl\":$OPUSDNS_TTL,\"rdata\":\"\\\"$txtvalue\\\"\"}}]}"; then + _err "Warning: Failed to remove TXT record" return 0 fi @@ -124,132 +76,83 @@ dns_opusdns_rm() { ######## Private functions ########### -# Detect zone from FQDN by checking against OpusDNS API -# Iterates through domain parts until a valid zone is found -# Sets global variables: _zone, _record_name -_get_zone() { - domain=$1 - _debug "Detecting zone for: $domain" - - # Remove trailing dot if present - domain=$(echo "$domain" | sed 's/\.$//') - - export _H1="X-Api-Key: $OPUSDNS_API_Key" - - # Start from position 2 (skip first part like _acme-challenge) - i=2 - p=1 - while true; do - # Extract potential zone (domain parts from position i onwards) - h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - _debug "Trying zone: $h" - - if [ -z "$h" ]; then - # No more parts to try - _err "Could not find a valid zone for: $domain" - return 1 - fi - - # Check if this zone exists in OpusDNS - response=$(_get "$OPUSDNS_API_Endpoint/v1/dns/$h") - - if _contains "$response" '"name"'; then - # Zone found - _record_name=$(printf "%s" "$domain" | cut -d . -f 1-"$p") - _zone="$h" - _debug "Found zone: $_zone" - _debug "Record name: $_record_name" - return 0 - fi +# Initialize and validate configuration +_opusdns_init() { + OPUSDNS_API_Key="${OPUSDNS_API_Key:-$(_readaccountconf_mutable OPUSDNS_API_Key)}" + OPUSDNS_API_Endpoint="${OPUSDNS_API_Endpoint:-$(_readaccountconf_mutable OPUSDNS_API_Endpoint)}" + OPUSDNS_TTL="${OPUSDNS_TTL:-$(_readaccountconf_mutable OPUSDNS_TTL)}" - _debug "$h not found, trying next" - p="$i" - i=$(_math "$i" + 1) - done + if [ -z "$OPUSDNS_API_Key" ]; then + _err "OPUSDNS_API_Key not set" + return 1 + fi - return 1 -} + [ -z "$OPUSDNS_API_Endpoint" ] && OPUSDNS_API_Endpoint="$OPUSDNS_API_Endpoint_Default" + [ -z "$OPUSDNS_TTL" ] && OPUSDNS_TTL="$OPUSDNS_TTL_Default" - if [ -z "$_record_name" ]; then - _record_name="@" - fi + _saveaccountconf_mutable OPUSDNS_API_Key "$OPUSDNS_API_Key" + _saveaccountconf_mutable OPUSDNS_API_Endpoint "$OPUSDNS_API_Endpoint" + _saveaccountconf_mutable OPUSDNS_TTL "$OPUSDNS_TTL" + _debug "Endpoint: $OPUSDNS_API_Endpoint" return 0 } -# Add TXT record using OpusDNS API -_opusdns_add_record() { - zone=$1 - record_name=$2 - txtvalue=$3 +# Make API request +# Usage: _opusdns_api METHOD PATH [DATA] +_opusdns_api() { + method=$1 + path=$2 + data=$3 - _debug "Adding TXT record: $record_name.$zone = $txtvalue" - - # Escape all JSON special characters in txtvalue - # Order matters: escape backslashes first, then other characters - escaped_value=$(printf '%s' "$txtvalue" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' | sed ':a;N;$!ba;s/\n/\\n/g') - - # Build JSON payload - # Note: TXT records need quotes around the value in rdata - json_payload="{\"ops\":[{\"op\":\"upsert\",\"record\":{\"name\":\"$record_name\",\"type\":\"TXT\",\"ttl\":$OPUSDNS_TTL,\"rdata\":\"\\\"$escaped_value\\\"\"}}]}" - - _debug2 "JSON payload: $json_payload" - - # Send PATCH request export _H1="X-Api-Key: $OPUSDNS_API_Key" export _H2="Content-Type: application/json" - response=$(_post "$json_payload" "$OPUSDNS_API_Endpoint/v1/dns/$zone/records" "" "PATCH") - status=$? - - _debug2 "API Response: $response" + url="$OPUSDNS_API_Endpoint$path" + _debug2 "API: $method $url" + [ -n "$data" ] && _debug2 "Data: $data" - if [ $status -ne 0 ]; then - _err "Failed to add TXT record" - _err "API Response: $response" - return 1 + if [ -n "$data" ]; then + response=$(_post "$data" "$url" "" "$method") + else + response=$(_get "$url") fi - # Check for error in response (OpusDNS returns JSON error even on failure) - # Use anchored pattern to avoid matching field names like "error_count" - if echo "$response" | grep -q '"error":'; then - _err "API returned error: $response" + if [ $? -ne 0 ]; then + _err "API request failed" + _debug "Response: $response" return 1 fi + _debug2 "Response: $response" return 0 } -# Remove TXT record using OpusDNS API -_opusdns_remove_record() { - zone=$1 - record_name=$2 - txtvalue=$3 - - _debug "Removing TXT record: $record_name.$zone = $txtvalue" - - # Escape all JSON special characters in txtvalue (same as add) - escaped_value=$(printf '%s' "$txtvalue" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' | sed ':a;N;$!ba;s/\n/\\n/g') - - # Build JSON payload for removal - needs complete record specification - json_payload="{\"ops\":[{\"op\":\"remove\",\"record\":{\"name\":\"$record_name\",\"type\":\"TXT\",\"ttl\":$OPUSDNS_TTL,\"rdata\":\"\\\"$escaped_value\\\"\"}}]}" - - _debug2 "JSON payload: $json_payload" - - # Send PATCH request - export _H1="X-Api-Key: $OPUSDNS_API_Key" - export _H2="Content-Type: application/json" +# Detect zone from FQDN +# Sets: _zone, _record_name +_get_zone() { + domain=$(echo "$1" | sed 's/\.$//') + _debug "Finding zone for: $domain" - response=$(_post "$json_payload" "$OPUSDNS_API_Endpoint/v1/dns/$zone/records" "" "PATCH") - status=$? + i=2 + p=1 + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - _debug2 "API Response: $response" + if [ -z "$h" ]; then + _err "No valid zone found for: $domain" + return 1 + fi - if [ $status -ne 0 ]; then - _err "Failed to remove TXT record" - _err "API Response: $response" - return 1 - fi + _debug "Trying: $h" + if _opusdns_api GET "/v1/dns/$h" && _contains "$response" '"name"'; then + _zone="$h" + _record_name=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + [ -z "$_record_name" ] && _record_name="@" + return 0 + fi - return 0 + p="$i" + i=$(_math "$i" + 1) + done } From 2e85e6f9bb0587450d9fea755203538c6dedab13 Mon Sep 17 00:00:00 2001 From: Kilian Ries Date: Thu, 22 Jan 2026 18:13:51 +0100 Subject: [PATCH 09/10] Fix zone detection: check for dnssec_status instead of name The error response also contains 'name' in 'zone_name' field, causing false positives. Check for 'dnssec_status' which only exists in valid zone responses. --- dnsapi/dns_opusdns.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_opusdns.sh b/dnsapi/dns_opusdns.sh index 19205256..2ef401eb 100755 --- a/dnsapi/dns_opusdns.sh +++ b/dnsapi/dns_opusdns.sh @@ -145,7 +145,7 @@ _get_zone() { fi _debug "Trying: $h" - if _opusdns_api GET "/v1/dns/$h" && _contains "$response" '"name"'; then + if _opusdns_api GET "/v1/dns/$h" && _contains "$response" '"dnssec_status"'; then _zone="$h" _record_name=$(printf "%s" "$domain" | cut -d . -f 1-"$p") [ -z "$_record_name" ] && _record_name="@" From 9c245eb37a2f6568a5f316ca7ba23a230acf4452 Mon Sep 17 00:00:00 2001 From: Kilian Ries Date: Fri, 23 Jan 2026 09:20:00 +0100 Subject: [PATCH 10/10] fix: start zone detection from i=1 per acme.sh convention --- dnsapi/dns_opusdns.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_opusdns.sh b/dnsapi/dns_opusdns.sh index 2ef401eb..37177696 100755 --- a/dnsapi/dns_opusdns.sh +++ b/dnsapi/dns_opusdns.sh @@ -134,7 +134,7 @@ _get_zone() { domain=$(echo "$1" | sed 's/\.$//') _debug "Finding zone for: $domain" - i=2 + i=1 p=1 while true; do h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)