Clarify Porkbun credential and API errors #3

Merged
eleith merged 2 commits from fix-porkbun-ddns-credential-errors into main 2026-10-24 14:04:09 +00:00
5 changed files with 349 additions and 75 deletions

No files matched your search

+19 -2
View File
@@ -2,6 +2,12 @@ name: Build and Push porkbun-ddns
on:
workflow_dispatch:
pull_request:
branches:
- main
paths:
- "porkbun-ddns/**"
- ".github/workflows/build-porkbun-ddns.yml"
push:
branches:
- main
@@ -12,11 +18,10 @@ on:
- "porkbun-ddns/v*"
jobs:
build:
checks:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
@@ -27,6 +32,18 @@ jobs:
shellcheck porkbun-ddns/update.sh porkbun-ddns/tests/update_test.sh
bash porkbun-ddns/tests/update_test.sh
build:
if: github.event_name != 'pull_request'
needs: checks
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract tag name
id: tag_name
run: |
+4
View File
@@ -3,6 +3,10 @@ workspace:
path: app
when:
- event: pull_request
branch: main
path:
include: [".woodpecker/build-porkbun-ddns.yml", "porkbun-ddns/**"]
- event: push
branch: main
path:
+4 -14
View File
@@ -1,7 +1,6 @@
# 🐷 Porkbun DDNS
Updates one existing Porkbun A record to the host's public IPv4 address.
Runs once and exits; scheduling is handled externally.
## 🛠 Usage
@@ -21,11 +20,9 @@ Mount the file read-only to update `home.example.com`:
docker run --rm \
--user "$(id -u):$(id -g)" \
--mount type=bind,src=/path/to/porkbun.json,dst=/run/secrets/porkbun.json,readonly \
ghcr.io/eleith/containers-porkbun-ddns:1.0.0 example.com home
ghcr.io/eleith/containers-porkbun-ddns example.com home
```
`--user` lets the container read a credentials file protected for your host user.
### Arguments
| Argument | Description |
@@ -33,20 +30,13 @@ docker run --rm \
| `--dry-run` | Optional; place before the domain to report changes without updating DNS. Still calls Porkbun to check the IP and record. |
| `domain` | Porkbun zone, such as `example.com` or `example.co.uk`. |
| `name` | Relative record name, such as `home` or `services.home`; use `""` for the zone apex. |
| `--user` | lets the container read a credentials file protected for your host user. Without it, the image runs as UID/GID `65534:65534` |
Requires exactly one existing A record; never creates records. Skips unchanged
addresses and preserves the TTL. Errors exit nonzero. Use ASCII/punycode names
without trailing dots; wildcard names are not supported.
## 🏷 Tags
Release tags use `porkbun-ddns/vX.Y.Z` and publish `X`, `X.Y`, and `X.Y.Z`.
Pin a full version or digest; there is no `latest`. Pushes to `main` build and
test without publishing.
Requires exactly one existing A record;
## ✅ Tests
Local checks use mocked HTTP calls; no real credentials are needed:
Local checks use mocked HTTP calls
```bash
shellcheck porkbun-ddns/update.sh porkbun-ddns/tests/update_test.sh
+171 -19
View File
@@ -14,11 +14,22 @@ setup() {
export PING_RESPONSE='{"status":"SUCCESS","yourIp":"203.0.113.10"}'
export DNS_RESPONSE='{"status":"SUCCESS","records":[{"id":"123","name":"home.example.com","type":"A","content":"198.51.100.20","ttl":"600"}]}'
export EDIT_RESPONSE='{"status":"SUCCESS"}'
export FAIL_STAGE=''
export PING_HTTP_STATUS=200 DNS_HTTP_STATUS=200 EDIT_HTTP_STATUS=200
export FAIL_STAGE='' FAIL_CREDENTIAL_READ=false
}
# Simulate unreadable files without relying on the test runner's UID or chmod.
cat() {
if [[ $FAIL_CREDENTIAL_READ == true ]]; then
printf 'Mock read failure: test-secret\n' >&2
return 1
fi
command cat "$@"
}
export -f cat
# Exported into a fresh Bash process so errexit behaves as it does in production.
# Only HTTP is mocked; request construction and response parsing use real jq.
# Request construction and response parsing use real jq.
curl() {
local url=${!#} stage
printf '%s\n' "$@" >> "$CURL_ARGS"
@@ -30,13 +41,13 @@ curl() {
*) printf 'Unexpected URL: %s\n' "$url" >&2; return 1 ;;
esac
if [[ $FAIL_STAGE == "$stage" ]]; then
printf 'Mock HTTP failure\n' >&2
return 22
printf 'Mock transport failure\n' >&2
return 28
fi
case "$stage" in
ping) printf '%s\n' "$PING_RESPONSE" ;;
retrieve) printf '%s\n' "$DNS_RESPONSE" ;;
edit) printf '%s\n' "$EDIT_RESPONSE" ;;
ping) printf '%s\n%s' "$PING_RESPONSE" "$PING_HTTP_STATUS" ;;
retrieve) printf '%s\n%s' "$DNS_RESPONSE" "$DNS_HTTP_STATUS" ;;
edit) printf '%s\n%s' "$EDIT_RESPONSE" "$EDIT_HTTP_STATUS" ;;
esac
}
export -f curl
@@ -70,6 +81,16 @@ unchanged_record_is_not_written() {
assert_no_edit
}
reads_credentials_from_stdin() {
CREDENTIALS_FILE=/dev/stdin
run_updater --dry-run example.com home \
<<< '{"apikey":"test-key","secretapikey":"test-secret"}'
[[ $status -eq 0 && $output == 'Dry run: would update home.example.com A 198.51.100.20 -> 203.0.113.10' ]]
jq -es 'length == 2 and all(.[];
.body == {apikey: "test-key", secretapikey: "test-secret"})' "$REQUEST_LOG" > /dev/null
assert_no_edit
}
dry_run_reports_change_without_writing() {
FAIL_STAGE=edit
run_updater --dry-run example.com home
@@ -167,11 +188,37 @@ rejects_invalid_credentials_without_echoing_them() {
for credentials in '{"apikey":"test-secret"}' \
'{"apikey":"test-secret","secretapikey":""}' \
'{"apikey":false,"secretapikey":"test-secret"}' \
'test-secret is not JSON' \
'["test-secret"]' '"test-secret"' 'null' '' \
$'{"apikey":"test-secret","secretapikey":"test-secret"}\n{}'; do
printf '%s\n' "$credentials" > "$CREDENTIALS_FILE"
run_updater example.com home
[[ $status -ne 0 && $output != *test-secret* ]]
[[ $status -ne 0 && $output == *'one JSON object with non-empty string fields apikey and secretapikey'* ]]
[[ $output != *test-secret* ]]
done
[[ ! -s $REQUEST_LOG ]]
}
rejects_invalid_credentials_json() {
local credentials
for credentials in 'test-secret is not JSON' '{"apikey":"test-secret",}'; do
printf '%s\n' "$credentials" > "$CREDENTIALS_FILE"
run_updater example.com home
[[ $status -ne 0 && $output == *'Invalid JSON in credentials file'* ]]
[[ $output != *test-secret* ]]
done
[[ ! -s $REQUEST_LOG ]]
}
rejects_whitespace_in_credentials() {
local field value
for field in apikey secretapikey; do
for value in ' test-secret' 'test-secret ' 'test secret' $'test-secret\n' $'test\tsecret'; do
jq -n --arg field "$field" --arg value "$value" \
'{apikey: "test-key", secretapikey: "test-secret"} | .[$field] = $value' > "$CREDENTIALS_FILE"
run_updater --dry-run example.com home
[[ $status -ne 0 && $output == *'contains whitespace'* && $output == *'Re-copy the keys'* ]]
[[ $output != *test-secret* && $output != *'test secret'* ]]
done
done
[[ ! -s $REQUEST_LOG ]]
}
@@ -179,7 +226,23 @@ rejects_invalid_credentials_without_echoing_them() {
rejects_missing_credentials() {
rm "$CREDENTIALS_FILE"
run_updater example.com home
[[ $status -ne 0 && $output == *'Expected apikey and secretapikey'* ]]
[[ $status -ne 0 && $output == *'Credentials file not found or inaccessible'* && $output == *'bind mount'* ]]
[[ ! -s $REQUEST_LOG ]]
}
rejects_credentials_directories() {
rm "$CREDENTIALS_FILE"
mkdir "$CREDENTIALS_FILE"
run_updater example.com home
[[ $status -ne 0 && $output == *'Credentials path is a directory'* ]]
[[ ! -s $REQUEST_LOG ]]
}
reports_credentials_file_read_failures() {
FAIL_CREDENTIAL_READ=true
run_updater example.com home
[[ $status -ne 0 && $output == *'Cannot read credentials file'* && $output == *'--user'* ]]
[[ $output == *'UID:GID '* && $output != *test-secret* && $output != *'Invalid JSON'* ]]
[[ ! -s $REQUEST_LOG ]]
}
@@ -224,7 +287,21 @@ rejects_unexpected_record_fields() {
assert_no_edit
}
stops_on_http_failures() {
validates_ttl_before_unchanged_and_dry_run() {
local original=$DNS_RESPONSE
DNS_RESPONSE=$(jq '.records[0].ttl = "bad"' <<< "$original")
run_updater --dry-run example.com home
[[ $status -ne 0 && $output == 'Error: Porkbun returned an invalid record TTL.' ]]
DNS_RESPONSE=$(jq '.records[0] |= (.ttl = "bad" | .content = "203.0.113.10")' <<< "$original")
run_updater example.com home
[[ $status -ne 0 && $output == 'Error: Porkbun returned an invalid record TTL.' ]]
run_updater --dry-run example.com home
[[ $status -ne 0 && $output == 'Error: Porkbun returned an invalid record TTL.' ]]
assert_no_edit
}
stops_on_transport_failures() {
local stage expected
expected=0
for stage in ping retrieve edit; do
@@ -232,27 +309,91 @@ stops_on_http_failures() {
: > "$REQUEST_LOG"
FAIL_STAGE=$stage
run_updater example.com home
[[ $status -ne 0 && $output == *'Request failed:'* && $output != *Updated:* ]]
[[ $status -ne 0 && $output == *'Network request failed:'* && $output != *Updated:* ]]
[[ $output != *'valid IPv4 address'* && $output != *'DNS record list'* ]]
[[ $(jq -s 'length' "$REQUEST_LOG") -eq $expected ]]
done
}
rejects_unsuccessful_or_malformed_api_responses() {
rejects_unsuccessful_api_responses() {
local stage
for stage in PING_RESPONSE DNS_RESPONSE EDIT_RESPONSE; do
local saved=${!stage}
printf -v "$stage" '%s' '{"status":"ERROR","message":"test-secret"}'
run_updater example.com home
[[ $status -ne 0 && $output == *'Porkbun rejected the request:'* && $output == *'(HTTP 200).'* ]]
[[ $output != *test-secret* && $output != *Updated:* && $output != *permissions* ]]
printf -v "$stage" '%s' "$saved"
done
}
rejects_malformed_api_responses() {
local stage response
for stage in PING_RESPONSE DNS_RESPONSE EDIT_RESPONSE; do
local saved=${!stage}
for response in '{"status":"ERROR","message":"test-secret"}' \
'not JSON: test-secret' \
for response in 'not JSON: test-secret' '{"message":"test-secret"}' \
$'{"status":"SUCCESS"}\n{"status":"SUCCESS"}'; do
printf -v "$stage" '%s' "$response"
run_updater example.com home
[[ $status -ne 0 && $output == *'Unsuccessful or malformed API response:'* ]]
[[ $status -ne 0 && $output == *'Malformed API response:'* ]]
[[ $output != *test-secret* && $output != *Updated:* ]]
done
printf -v "$stage" '%s' "$saved"
done
}
reports_rejected_api_credentials() {
local response
PING_HTTP_STATUS=400
for response in '{"status":"ERROR","message":"Invalid API key: test-secret"}' \
'{"status":"ERROR","message":"Invalid secret API key: test-secret"}'; do
: > "$REQUEST_LOG"
PING_RESPONSE=$response
run_updater --dry-run example.com home
[[ $status -ne 0 && $output == *'Porkbun rejected the API credentials:'* && $output == *'HTTP 400'* ]]
[[ $output == *'matching, valid pair'* && $output != *test-secret* ]]
[[ $(jq -s 'length' "$REQUEST_LOG") -eq 1 ]]
assert_no_edit
done
PING_HTTP_STATUS=200
PING_RESPONSE='{"status":"SUCCESS","credentialsValid":false,"yourIp":"203.0.113.10"}'
run_updater example.com home
[[ $status -ne 0 && $output == *'Porkbun rejected the API credentials:'* ]]
assert_no_edit
}
reports_unauthorized_http_responses() {
local stage
for stage in PING_HTTP_STATUS DNS_HTTP_STATUS EDIT_HTTP_STATUS; do
printf -v "$stage" '%s' 401
run_updater example.com home
[[ $status -ne 0 && $output == *'Porkbun rejected the API credentials:'* && $output == *'HTTP 401'* ]]
[[ $output != *Updated:* ]]
printf -v "$stage" '%s' 200
done
}
reports_domain_api_access_denied() {
DNS_HTTP_STATUS=403
DNS_RESPONSE='{"status":"ERROR","message":"test-secret"}'
run_updater example.com home
[[ $status -ne 0 && $output == *'Porkbun denied API access:'* && $output == *'HTTP 403'* ]]
[[ $output == *"domain's API Access"* && $output == *"key's permissions"* && $output != *test-secret* ]]
assert_no_edit
}
reports_other_http_errors_without_raw_responses() {
local http_status
for http_status in 400 429 500 502; do
PING_HTTP_STATUS=$http_status
PING_RESPONSE='test-secret error page'
run_updater example.com home
[[ $status -ne 0 && $output == *"Porkbun request failed: /ping (HTTP $http_status)"* ]]
[[ $output != *test-secret* && $output != *'API credentials'* ]]
done
assert_no_edit
}
keeps_credentials_out_of_arguments_and_logs() {
printf '%s\n' '{"apikey":"key\"with\\escapes","secretapikey":"secret\"with\\escapes","content":"must-be-ignored"}' > "$CREDENTIALS_FILE"
run_updater example.com home
@@ -270,7 +411,7 @@ uses_bounded_requests_without_redirects() {
[[ $status -eq 0 ]]
grep -Fxq -- '--connect-timeout' "$CURL_ARGS"
grep -Fxq -- '--max-time' "$CURL_ARGS"
grep -Fxq -- '--fail' "$CURL_ARGS"
grep -Fxq -- '--write-out' "$CURL_ARGS"
grep -Fxq -- '@-' "$CURL_ARGS"
if grep -Eq -- '^(-L|--location)$' "$CURL_ARGS"; then
return 1
@@ -287,6 +428,7 @@ reports_test_failure_location() {
TESTS=(
updates_one_record_and_preserves_ttl
unchanged_record_is_not_written
reads_credentials_from_stdin
dry_run_reports_change_without_writing
dry_run_reports_unchanged_records
dry_run_supports_the_zone_apex
@@ -297,12 +439,22 @@ TESTS=(
refuses_multiple_records
rejects_invalid_arguments_before_network_access
rejects_invalid_credentials_without_echoing_them
rejects_invalid_credentials_json
rejects_whitespace_in_credentials
rejects_missing_credentials
rejects_credentials_directories
reports_credentials_file_read_failures
rejects_invalid_public_addresses
rejects_invalid_record_lists
rejects_unexpected_record_fields
stops_on_http_failures
rejects_unsuccessful_or_malformed_api_responses
validates_ttl_before_unchanged_and_dry_run
stops_on_transport_failures
rejects_unsuccessful_api_responses
rejects_malformed_api_responses
reports_rejected_api_credentials
reports_unauthorized_http_responses
reports_domain_api_access_denied
reports_other_http_errors_without_raw_responses
keeps_credentials_out_of_arguments_and_logs
uses_bounded_requests_without_redirects
reports_test_failure_location
+151 -40
View File
@@ -14,18 +14,153 @@ valid_hostname() {
[[ ${#1} -le 253 && $1 =~ ^$label(\.$label)*$ ]]
}
read_credentials() {
[[ -e $CREDENTIALS_FILE ]] || fail \
"Credentials file not found or inaccessible: $CREDENTIALS_FILE." \
'Check the Docker-host bind mount path and directory permissions.'
[[ ! -d $CREDENTIALS_FILE ]] || fail \
"Credentials path is a directory: $CREDENTIALS_FILE." \
'Mount a JSON file instead.'
local contents
contents=$(cat "$CREDENTIALS_FILE" 2>/dev/null) || fail \
"Cannot read credentials file $CREDENTIALS_FILE as UID:GID $(id -u):$(id -g)." \
"Check file permissions and the container's --user."
local documents
documents=$(jq -cs '.' <<< "$contents" 2>/dev/null) || fail \
"Invalid JSON in credentials file $CREDENTIALS_FILE." \
'Fix the JSON syntax.'
local auth
auth=$(jq -ce '
select(length == 1)
| .[0]
| select(type == "object")
| {apikey, secretapikey}
| select(all(.[]; type == "string" and length > 0))
' <<< "$documents" 2>/dev/null) || fail \
"Credentials file $CREDENTIALS_FILE must contain one JSON object" \
'with non-empty string fields apikey and secretapikey.'
jq -e 'all(.[]; test("\\s") | not)' <<< "$auth" > /dev/null || fail \
"apikey or secretapikey contains whitespace in $CREDENTIALS_FILE." \
'Re-copy the keys without whitespace.'
printf '%s\n' "$auth"
}
# Request bodies arrive on stdin so credentials never enter curl's arguments.
api_request() {
local path=$1 response
response=$(curl --disable --silent --show-error --fail \
local path=$1
local curl_output
curl_output=$(curl --disable --silent --show-error \
--connect-timeout 10 --max-time 30 \
--header 'Content-Type: application/json' \
--data-binary @- "${API_URL}${path}") || fail "Request failed: $path"
--write-out '\n%{http_code}' \
--data-binary @- "${API_URL}${path}") || fail \
"Network request failed: $path." \
'Check connectivity, DNS, and TLS.'
# Do not print raw API errors: they could contain sensitive information.
jq -ces 'select(length == 1) | .[0] |
select(type == "object" and .status == "SUCCESS")' \
<<< "$response" 2>/dev/null || fail "Unsuccessful or malformed API response: $path"
local http_status=${curl_output##*$'\n'}
local response_body=${curl_output%$'\n'*}
# Keep error bodies for classification, but never log raw responses or keys.
local api_response
api_response=$(jq -ces '
select(length == 1)
| .[0]
| select(type == "object")
| select(.status | type == "string")
' <<< "$response_body" 2>/dev/null) || api_response=''
if [[ $http_status == 401 ]] || jq -e '
.credentialsValid == false
or any(.message | strings; test("invalid (secret )?api key"; "i"))
' <<< "$api_response" > /dev/null 2>&1; then
fail \
"Porkbun rejected the API credentials: $path (HTTP $http_status)." \
'Check that apikey and secretapikey are a matching, valid pair.'
fi
if [[ $http_status == 403 ]]; then
fail \
"Porkbun denied API access: $path (HTTP 403)." \
"Check the domain's API Access setting and the key's permissions" \
'and domain/IP restrictions.'
fi
[[ $http_status == 2[0-9][0-9] ]] \
|| fail "Porkbun request failed: $path (HTTP $http_status)."
[[ -n $api_response ]] \
|| fail "Malformed API response: $path (HTTP $http_status)."
jq -e '.status == "SUCCESS"' <<< "$api_response" > /dev/null \
|| fail "Porkbun rejected the request: $path (HTTP $http_status)."
printf '%s\n' "$api_response"
}
get_public_ipv4() {
local ping
ping=$(api_request /ping) || return 1
jq -er '
.yourIp
| select(type == "string")
| select(test("^(0|[1-9][0-9]{0,2})(\\.(0|[1-9][0-9]{0,2})){3}$"))
| select(split(".") | all(.[]; tonumber <= 255))
' <<< "$ping" 2>/dev/null || fail 'Porkbun did not return a valid IPv4 address.'
}
get_single_a_record() {
local domain=$1 name=$2 target=$3
local path="/dns/retrieveByNameType/$domain/A"
if [[ -n $name ]]; then
path+="/$name"
fi
local response
response=$(api_request "$path") || return 1
local records
records=$(jq -ce '.records | select(type == "array")' <<< "$response" 2>/dev/null) \
|| fail 'Porkbun did not return a DNS record list.'
local count
count=$(jq 'length' <<< "$records")
[[ $count -eq 1 ]] || fail \
"Expected exactly one existing A record for $target; found $count." \
'Refusing to update.'
local record
record=$(jq -ce --arg target "$target" '
.[0]
| select(.type == "A")
| select(.name | type == "string")
| select((.name | ascii_downcase | rtrimstr(".")) == $target)
| select(.id | type == "string" and test("^[0-9]+$"))
| select(.content | type == "string" and length > 0)
' <<< "$records" 2>/dev/null) || fail \
'Porkbun returned an invalid or unexpected DNS record.'
jq -e '.ttl | tonumber | select(. > 0 and . == floor)' \
<<< "$record" > /dev/null 2>&1 || fail 'Porkbun returned an invalid record TTL.'
printf '%s\n' "$record"
}
update_existing_record() {
local domain=$1 name=$2 record=$3 public_ip=$4
local record_id ttl
record_id=$(jq -r '.id' <<< "$record")
ttl=$(jq '.ttl | tonumber' <<< "$record")
jq -c \
--arg name "$name" \
--arg public_ip "$public_ip" \
--argjson ttl "$ttl" '
. + {
name: $name,
type: "A",
content: $public_ip,
ttl: $ttl
}
' | api_request "/dns/edit/$domain/$record_id" > /dev/null
}
main() {
@@ -34,7 +169,9 @@ main() {
dry_run=true
shift
fi
[[ $# -eq 2 ]] || fail 'Usage: porkbun-ddns [--dry-run] <domain> <name> (use "" for the domain apex)'
[[ $# -eq 2 ]] || fail \
'Usage: porkbun-ddns [--dry-run] <domain> <name>' \
'(use "" for the domain apex)'
local domain=${1,,} name=${2,,} target
valid_hostname "$domain" || fail 'Invalid domain; use a DNS name without a trailing dot.'
target=$domain
@@ -45,37 +182,14 @@ main() {
valid_hostname "$target" || fail 'The full record name is too long.'
local auth
auth=$(jq -ces 'select(length == 1) | .[0] | {apikey, secretapikey} |
select(all(.[]; type == "string" and length > 0))' \
"$CREDENTIALS_FILE" 2>/dev/null) || fail "Expected apikey and secretapikey in $CREDENTIALS_FILE"
auth=$(read_credentials)
local ping public_ip
ping=$(printf '%s\n' "$auth" | api_request /ping)
public_ip=$(jq -er '.yourIp | select(type == "string") |
select(test("^(0|[1-9][0-9]{0,2})(\\.(0|[1-9][0-9]{0,2})){3}$")) |
select(split(".") | all(.[]; tonumber <= 255))' \
<<< "$ping" 2>/dev/null) || fail 'Porkbun did not return a valid IPv4 address.'
local public_ip
public_ip=$(printf '%s\n' "$auth" | get_public_ipv4)
local path response records count record record_id current_ip ttl
path="/dns/retrieveByNameType/$domain/A"
[[ -z $name ]] || path+="/$name"
response=$(printf '%s\n' "$auth" | api_request "$path")
records=$(jq -ce '.records | select(type == "array")' <<< "$response" 2>/dev/null) \
|| fail 'Porkbun did not return a DNS record list.'
count=$(jq 'length' <<< "$records")
[[ $count -eq 1 ]] || fail "Expected exactly one existing A record for $target; found $count. Refusing to update."
record=$(jq -ce --arg target "$target" '.[0] |
select(.type == "A") |
select(.name | type == "string") |
select((.name | ascii_downcase | rtrimstr(".")) == $target) |
select(.id | type == "string" and test("^[0-9]+$")) |
select(.content | type == "string" and length > 0)' \
<<< "$records" 2>/dev/null) || fail 'Porkbun returned an invalid or unexpected DNS record.'
record_id=$(jq -r '.id' <<< "$record")
local record current_ip
record=$(printf '%s\n' "$auth" | get_single_a_record "$domain" "$name" "$target")
current_ip=$(jq -r '.content' <<< "$record")
ttl=$(jq -er '.ttl | tonumber | select(. > 0 and . == floor)' \
<<< "$record" 2>/dev/null) || fail 'Porkbun returned an invalid record TTL.'
if [[ $current_ip == "$public_ip" ]]; then
printf 'Unchanged: %s A %s\n' "$target" "$public_ip"
@@ -87,10 +201,7 @@ main() {
return
fi
# Send the existing name/type/TTL explicitly; edit only this record's ID.
jq -c --arg name "$name" --arg public_ip "$public_ip" --argjson ttl "$ttl" \
'. + {name: $name, type: "A", content: $public_ip, ttl: $ttl}' <<< "$auth" \
| api_request "/dns/edit/$domain/$record_id" > /dev/null
printf '%s\n' "$auth" | update_existing_record "$domain" "$name" "$record" "$public_ip"
printf 'Updated: %s A %s -> %s\n' "$target" "$current_ip" "$public_ip"
}