diff --git a/.github/workflows/build-porkbun-ddns.yml b/.github/workflows/build-porkbun-ddns.yml index b92b659..75e59c9 100644 --- a/.github/workflows/build-porkbun-ddns.yml +++ b/.github/workflows/build-porkbun-ddns.yml @@ -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: | diff --git a/.woodpecker/build-porkbun-ddns.yml b/.woodpecker/build-porkbun-ddns.yml index 00be6a0..25d8f01 100644 --- a/.woodpecker/build-porkbun-ddns.yml +++ b/.woodpecker/build-porkbun-ddns.yml @@ -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: diff --git a/porkbun-ddns/README.md b/porkbun-ddns/README.md index e77e605..be4eb99 100644 --- a/porkbun-ddns/README.md +++ b/porkbun-ddns/README.md @@ -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 diff --git a/porkbun-ddns/tests/update_test.sh b/porkbun-ddns/tests/update_test.sh index 502ec5c..3767b9f 100644 --- a/porkbun-ddns/tests/update_test.sh +++ b/porkbun-ddns/tests/update_test.sh @@ -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 diff --git a/porkbun-ddns/update.sh b/porkbun-ddns/update.sh index 2ef41be..ca5735c 100644 --- a/porkbun-ddns/update.sh +++ b/porkbun-ddns/update.sh @@ -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] (use "" for the domain apex)' + [[ $# -eq 2 ]] || fail \ + 'Usage: porkbun-ddns [--dry-run] ' \ + '(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" }