Clarify Porkbun credential and API errors #3
No files matched your search
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user