Add update-only Porkbun DDNS container #2

Merged
eleith merged 1 commits from add-porkbun-ddns into main 2026-10-24 01:05:50 +00:00
10 changed files with 663 additions and 15 deletions

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
name: Build and Push porkbun-ddns
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "porkbun-ddns/**"
- ".github/workflows/build-porkbun-ddns.yml"
tags:
- "porkbun-ddns/v*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Check and test script
run: |
shellcheck porkbun-ddns/update.sh porkbun-ddns/tests/update_test.sh
bash porkbun-ddns/tests/update_test.sh
- name: Extract tag name
id: tag_name
run: |
if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
VERSION="${GITHUB_REF_NAME#porkbun-ddns/v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
fi
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/containers-porkbun-ddns
flavor: latest=false
tags: |
type=semver,pattern={{version}},value=${{ steps.tag_name.outputs.version }},enable=${{ github.ref_type == 'tag' }}
type=semver,pattern={{major}}.{{minor}},value=${{ steps.tag_name.outputs.version }},enable=${{ github.ref_type == 'tag' }}
type=semver,pattern={{major}},value=${{ steps.tag_name.outputs.version }},enable=${{ github.ref_type == 'tag' }}
- name: Login to GitHub Container Registry
if: github.ref_type == 'tag'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: ./porkbun-ddns
file: ./porkbun-ddns/Dockerfile
push: ${{ github.ref_type == 'tag' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+1
View File
@@ -18,6 +18,7 @@ jobs:
- containers-hledger
- containers-nvim
- containers-playwright
- containers-porkbun-ddns
- containers-ssh
- containers-wg-verify
- containers-yq
+75
View File
@@ -0,0 +1,75 @@
workspace:
base: /workspace
path: app
when:
- event: push
branch: main
path:
include: [".woodpecker/build-porkbun-ddns.yml", "porkbun-ddns/**"]
- event: tag
ref: "refs/tags/porkbun-ddns/v*.*.*"
steps:
test:
image: alpine:3.23
commands:
- apk add --no-cache bash jq shellcheck
- shellcheck porkbun-ddns/update.sh porkbun-ddns/tests/update_test.sh
- bash porkbun-ddns/tests/update_test.sh
extract-semver-tags:
image: alpine/git:v2.45.2
commands:
- |
VERSION_FULL="${CI_COMMIT_TAG#porkbun-ddns/v}"
VERSION_MAJOR=$(echo "$VERSION_FULL" | cut -d. -f1)
VERSION_MINOR=$(echo "$VERSION_FULL" | cut -d. -f2)
echo "Extracted versions: $VERSION_FULL, $VERSION_MAJOR.$VERSION_MINOR, $VERSION_MAJOR"
printf '%s\n' "$VERSION_FULL" "$VERSION_MAJOR" "$VERSION_MAJOR.$VERSION_MINOR" > .tags
when:
- event: tag
build-docker-main:
image: woodpeckerci/plugin-docker-buildx:6.0.3
settings:
repo: git.eleith.com/eleith.private/porkbun-ddns
dockerfile: porkbun-ddns/Dockerfile
context: porkbun-ddns
dry_run: true
when:
- event: push
branch: main
build-docker-tag:
image: woodpeckerci/plugin-docker-buildx:6.0.3
settings:
repo: git.eleith.com/eleith.private/porkbun-ddns
username: eleith
dockerfile: porkbun-ddns/Dockerfile
context: porkbun-ddns
registry: https://git.eleith.com
password:
from_secret: gitea_docker_registry
tags_file: .tags
when:
- event: tag
notify:
image: deblan/woodpecker-email:3.3.0
settings:
dsn:
from_secret: woodpecker_email_dsn
from:
address:
from_secret: woodpecker_email_from_address
name:
from_secret: woodpecker_email_from_name
recipients:
from_secret: woodpecker_email_recipient
recipients_only: true
when:
- event: push
branch: main
status: [failure, success]
+20 -15
View File
@@ -4,40 +4,45 @@ Welcome to the utility belt. To help you help us, here's some context and rules
## 📜 Principles
- **Alpine First:** We use Alpine Linux for all our containers. Keep images small and focused.
- **Security:** Do not hardcode credentials. Use secrets or environment variables.
- **Alpine First:** Prefer Alpine where suitable. Keep images small and focused; document why another base is needed.
- **Security:** Do not hardcode credentials. Prefer read-only mounted secrets; keep credentials out of logs and command arguments.
- **Accuracy:** Scripts like `check.sh` are precise. When updating them, ensure you maintain the core DANE/TLSA logic correctly.
## 🛠 Working with the Codebase
### Dockerfiles
- Stick to stable Alpine versions (e.g., `3.20.1` or current LTS).
- Use supported stable base-image versions and install only the dependencies the tool needs.
- Minimize layers by combining `RUN` commands where it makes sense.
- Use `ENTRYPOINT` for main commands in specialized containers like `dane-verify`.
### Scripts
- Bash is fine, but keep it readable. Use `set -e` for safety.
- `awk`, `sed`, and `grep` are our best friends for parsing output from tools like `dig` and `openssl`.
- Use `jq` for JSON; `awk`, `sed`, and `grep` are useful for text output from tools like `dig` and `openssl`.
- Bound network requests with timeouts and return nonzero on failures.
### CI/CD
- We use [Woodpecker CI](https://woodpecker-ci.org/). Check `.woodpecker/*.yml` files for the latest build steps and secrets usage.
- We use both GitHub Actions and [Woodpecker CI](https://woodpecker-ci.org/). Keep their build, test, and publishing behavior aligned.
- Prefer the newer `hledger` and `porkbun-ddns` workflows as examples; older workflows may use different publishing conventions.
## ✅ New Container Checklist
- **Purpose:** One clear command contract. Keep scheduling outside one-shot tools.
- **Image:** Minimal supported base, documented exceptions, and only necessary dependencies.
- **Privileges:** Run as non-root where practical. Document required users, mounts, and capabilities.
- **Tests:** Run lint and offline tests before building in both CI systems. Make failures easy to locate.
- **Releases:** For new images, build `main` without publishing and publish versioned release tags, not `latest`. Document tag names, show pinned usage examples, and register the package in `.github/workflows/cleanup.yml`.
- **Docs:** Brief purpose, working usage example, arguments, and important runtime requirements. No shared framework is required.
## ✅ How to Test
Before you submit a PR, try building the containers:
Run each container's documented local checks. For example:
```bash
docker build -t dane-verify ./dane-verify
docker build -t ssh ./ssh
shellcheck porkbun-ddns/update.sh porkbun-ddns/tests/update_test.sh
bash porkbun-ddns/tests/update_test.sh
```
If you can, run a quick check (e.g., against a known-good mail server for DANE):
```bash
docker run --rm dane-verify mail.google.com
```
(Note: Not all servers have TLSA records, so find a target that does!)
The user handles Docker builds and runs. Provide commands when needed, but do not execute Docker commands. Live API checks require explicit user approval.
## 🤝 Need Help?
+3
View File
@@ -7,6 +7,8 @@ ci and cd.
- [**dane-verify**](./dane-verify): A specialized tool to monitor a domain's
DNSSEC and TLSA records
- [**porkbun-ddns**](./porkbun-ddns): Updates one existing Porkbun A record to
the host's public IPv4 address
- [**hledger**](./hledger): hledger 1.99.5 (2.0 preview) for journal validation
and reporting in CI
- [**nvim**](./nvim): Neovim, `lua-language-server`, and `stylua` for
@@ -23,6 +25,7 @@ Each container lives in its own directory with its own `Dockerfile`. To build th
```bash
docker build -t dane-verify ./dane-verify
docker build -t porkbun-ddns ./porkbun-ddns
docker build --platform linux/amd64 -t hledger ./hledger
docker build -t nvim ./nvim
docker build -t ssh ./ssh
+2
View File
@@ -0,0 +1,2 @@
*
!update.sh
+9
View File
@@ -0,0 +1,9 @@
FROM alpine:3.23
RUN apk add --no-cache bash ca-certificates curl jq
COPY --chmod=755 update.sh /usr/local/bin/porkbun-ddns
USER 65534:65534
ENTRYPOINT ["/usr/local/bin/porkbun-ddns"]
+59
View File
@@ -0,0 +1,59 @@
# 🐷 Porkbun DDNS
Updates one existing Porkbun A record to the host's public IPv4 address.
Runs once and exits; scheduling is handled externally.
## 🛠 Usage
Enable API access for your domain and store your credentials in a protected JSON
file outside this repository:
```json
{
"apikey": "YOUR_API_KEY",
"secretapikey": "YOUR_SECRET_API_KEY"
}
```
Mount the file read-only to update `home.example.com`:
```bash
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
```
`--user` lets the container read a credentials file protected for your host user.
### Arguments
| Argument | Description |
|----------|-------------|
| `--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. |
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.
## ✅ Tests
Local checks use mocked HTTP calls; no real credentials are needed:
```bash
shellcheck porkbun-ddns/update.sh porkbun-ddns/tests/update_test.sh
bash porkbun-ddns/tests/update_test.sh
```
## 📦 Tech Stack
- **Base:** Alpine Linux
- **Tools:** Bash, curl, jq
+330
View File
@@ -0,0 +1,330 @@
#!/bin/bash
set -Eeuo pipefail
SCRIPT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/update.sh
setup() {
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT
CREDENTIALS_FILE=$WORK_DIR/porkbun.json
export REQUEST_LOG=$WORK_DIR/requests.jsonl CURL_ARGS=$WORK_DIR/curl-args
: > "$REQUEST_LOG"
: > "$CURL_ARGS"
printf '%s\n' '{"apikey":"test-key","secretapikey":"test-secret"}' > "$CREDENTIALS_FILE"
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=''
}
# 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.
curl() {
local url=${!#} stage
printf '%s\n' "$@" >> "$CURL_ARGS"
jq -c --arg url "$url" '{url: $url, body: .}' >> "$REQUEST_LOG"
case "$url" in
*/ping) stage=ping ;;
*/dns/retrieveByNameType/*) stage=retrieve ;;
*/dns/edit/*) stage=edit ;;
*) printf 'Unexpected URL: %s\n' "$url" >&2; return 1 ;;
esac
if [[ $FAIL_STAGE == "$stage" ]]; then
printf 'Mock HTTP failure\n' >&2
return 22
fi
case "$stage" in
ping) printf '%s\n' "$PING_RESPONSE" ;;
retrieve) printf '%s\n' "$DNS_RESPONSE" ;;
edit) printf '%s\n' "$EDIT_RESPONSE" ;;
esac
}
export -f curl
run_updater() {
status=0
output=$(bash -c 'source "$1"; CREDENTIALS_FILE=$2; shift 2; main "$@"' \
_ "$SCRIPT" "$CREDENTIALS_FILE" "$@" 2>&1) || status=$?
}
assert_no_edit() {
jq -es 'all(.[]; .url | contains("/dns/edit/") | not)' "$REQUEST_LOG" > /dev/null
}
updates_one_record_and_preserves_ttl() {
run_updater example.com home
[[ $status -eq 0 && $output == 'Updated: home.example.com A 198.51.100.20 -> 203.0.113.10' ]]
jq -es 'length == 3 and
.[0].url == "https://api-ipv4.porkbun.com/api/json/v3/ping" and
.[1].url == "https://api-ipv4.porkbun.com/api/json/v3/dns/retrieveByNameType/example.com/A/home" and
.[2].url == "https://api-ipv4.porkbun.com/api/json/v3/dns/edit/example.com/123" and
.[2].body == {apikey: "test-key", secretapikey: "test-secret",
name: "home", type: "A", content: "203.0.113.10", ttl: 600}' "$REQUEST_LOG" > /dev/null
}
unchanged_record_is_not_written() {
DNS_RESPONSE=${DNS_RESPONSE/198.51.100.20/203.0.113.10}
run_updater example.com home
[[ $status -eq 0 && $output == 'Unchanged: home.example.com A 203.0.113.10' ]]
[[ $(jq -s 'length' "$REQUEST_LOG") -eq 2 ]]
assert_no_edit
}
dry_run_reports_change_without_writing() {
FAIL_STAGE=edit
run_updater --dry-run example.com home
[[ $status -eq 0 && $output == 'Dry run: would update home.example.com A 198.51.100.20 -> 203.0.113.10' ]]
[[ $(jq -s 'length' "$REQUEST_LOG") -eq 2 ]]
assert_no_edit
}
dry_run_reports_unchanged_records() {
DNS_RESPONSE=${DNS_RESPONSE/198.51.100.20/203.0.113.10}
run_updater --dry-run example.com home
[[ $status -eq 0 && $output == 'Unchanged: home.example.com A 203.0.113.10' ]]
[[ $(jq -s 'length' "$REQUEST_LOG") -eq 2 ]]
assert_no_edit
}
dry_run_supports_the_zone_apex() {
DNS_RESPONSE=${DNS_RESPONSE/home.example.com/example.com}
run_updater --dry-run example.com ''
[[ $status -eq 0 && $output == 'Dry run: would update example.com A 198.51.100.20 -> 203.0.113.10' ]]
[[ $(jq -s 'length' "$REQUEST_LOG") -eq 2 ]]
assert_no_edit
}
dry_run_refuses_missing_or_multiple_records() {
local original=$DNS_RESPONSE
DNS_RESPONSE='{"status":"SUCCESS","records":[]}'
run_updater --dry-run example.com home
[[ $status -ne 0 && $output == *'found 0. Refusing to update.'* ]]
DNS_RESPONSE=$(jq '.records += .records' <<< "$original")
run_updater --dry-run example.com home
[[ $status -ne 0 && $output == *'found 2. Refusing to update.'* ]]
assert_no_edit
}
supports_the_zone_apex() {
DNS_RESPONSE=${DNS_RESPONSE/home.example.com/example.com}
run_updater example.com ''
[[ $status -eq 0 ]]
jq -es '.[1].url | endswith("/retrieveByNameType/example.com/A")' "$REQUEST_LOG" > /dev/null
jq -es '.[2].body.name == ""' "$REQUEST_LOG" > /dev/null
}
supports_nested_names_and_multilabel_zones() {
DNS_RESPONSE=$(jq '.records[0] |= (.name = "Services.Home.Example.co.uk." | .ttl = 1200)' <<< "$DNS_RESPONSE")
run_updater EXAMPLE.CO.UK Services.Home
[[ $status -eq 0 ]]
jq -es '.[1].url | endswith("/example.co.uk/A/services.home")' "$REQUEST_LOG" > /dev/null
jq -es '.[2].body.name == "services.home" and .[2].body.ttl == 1200' "$REQUEST_LOG" > /dev/null
}
refuses_missing_records() {
DNS_RESPONSE='{"status":"SUCCESS","records":[]}'
run_updater example.com home
[[ $status -ne 0 && $output == *'found 0. Refusing to update.'* ]]
assert_no_edit
}
refuses_multiple_records() {
DNS_RESPONSE=$(jq '.records += .records' <<< "$DNS_RESPONSE")
run_updater example.com home
[[ $status -ne 0 && $output == *'found 2. Refusing to update.'* ]]
assert_no_edit
}
rejects_invalid_arguments_before_network_access() {
run_updater
[[ $status -ne 0 && $output == *Usage:* ]]
run_updater example.com
[[ $status -ne 0 && $output == *Usage:* ]]
run_updater example.com home extra
[[ $status -ne 0 && $output == *Usage:* ]]
run_updater --dry-run
[[ $status -ne 0 && $output == *Usage:* ]]
run_updater --dry-run example.com
[[ $status -ne 0 && $output == *Usage:* ]]
run_updater --dry-run example.com home extra
[[ $status -ne 0 && $output == *Usage:* ]]
local domain name
for domain in 'example.com/' 'example.com?x=y' 'example.com.' '-example.com' 'ex..com'; do
run_updater "$domain" home
[[ $status -ne 0 ]]
done
for name in '../home' 'home#fragment' 'home/name' 'bad name'; do
run_updater example.com "$name"
[[ $status -ne 0 ]]
done
run_updater example.com "$(printf 'a%.0s' {1..64})"
[[ $status -ne 0 ]]
[[ ! -s $REQUEST_LOG ]]
}
rejects_invalid_credentials_without_echoing_them() {
local credentials
for credentials in '{"apikey":"test-secret"}' \
'{"apikey":"test-secret","secretapikey":""}' \
'{"apikey":false,"secretapikey":"test-secret"}' \
'test-secret is not JSON' \
$'{"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* ]]
done
[[ ! -s $REQUEST_LOG ]]
}
rejects_missing_credentials() {
rm "$CREDENTIALS_FILE"
run_updater example.com home
[[ $status -ne 0 && $output == *'Expected apikey and secretapikey'* ]]
[[ ! -s $REQUEST_LOG ]]
}
rejects_invalid_public_addresses() {
local ip
for ip in '2001:db8::1' '256.0.0.1' '1.2.3' '1.2.3.4.5' '01.2.3.4' 'not an IP' ''; do
: > "$REQUEST_LOG"
PING_RESPONSE=$(jq -cn --arg ip "$ip" '{status: "SUCCESS", yourIp: $ip}')
run_updater example.com home
[[ $status -ne 0 && $output == *'valid IPv4 address'* ]]
[[ $(jq -s 'length' "$REQUEST_LOG") -eq 1 ]]
done
PING_RESPONSE='{"status":"SUCCESS"}'
run_updater example.com home
[[ $status -ne 0 ]]
assert_no_edit
}
rejects_invalid_record_lists() {
local response
for response in '{"status":"SUCCESS"}' '{"status":"SUCCESS","records":{}}'; do
DNS_RESPONSE=$response
run_updater example.com home
[[ $status -ne 0 && $output == *'DNS record list'* ]]
done
assert_no_edit
}
rejects_unexpected_record_fields() {
local original=$DNS_RESPONSE filter
for filter in '.records[0].type = "AAAA"' \
'.records[0].name = "other.example.com"' \
'.records[0].id = "123/other"' \
'del(.records[0].content)' \
'.records[0].ttl = "bad"' \
'.records[0].ttl = 0' \
'.records[0].ttl = 1.5'; do
DNS_RESPONSE=$(jq "$filter" <<< "$original")
run_updater example.com home
[[ $status -ne 0 ]]
done
assert_no_edit
}
stops_on_http_failures() {
local stage expected
expected=0
for stage in ping retrieve edit; do
expected=$((expected + 1))
: > "$REQUEST_LOG"
FAIL_STAGE=$stage
run_updater example.com home
[[ $status -ne 0 && $output == *'Request failed:'* && $output != *Updated:* ]]
[[ $(jq -s 'length' "$REQUEST_LOG") -eq $expected ]]
done
}
rejects_unsuccessful_or_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' \
$'{"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:'* ]]
[[ $output != *test-secret* && $output != *Updated:* ]]
done
printf -v "$stage" '%s' "$saved"
done
}
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
[[ $status -eq 0 && $output != *secret* && $output != *escapes* ]]
if grep -Eq 'escapes|must-be-ignored' "$CURL_ARGS"; then
return 1
fi
jq -es 'all(.[]; .body.apikey == "key\"with\\escapes" and
.body.secretapikey == "secret\"with\\escapes") and
(.[0].body | keys == ["apikey", "secretapikey"])' "$REQUEST_LOG" > /dev/null
}
uses_bounded_requests_without_redirects() {
run_updater example.com home
[[ $status -eq 0 ]]
grep -Fxq -- '--connect-timeout' "$CURL_ARGS"
grep -Fxq -- '--max-time' "$CURL_ARGS"
grep -Fxq -- '--fail' "$CURL_ARGS"
grep -Fxq -- '@-' "$CURL_ARGS"
if grep -Eq -- '^(-L|--location)$' "$CURL_ARGS"; then
return 1
fi
}
reports_test_failure_location() {
local diagnostic failure_status=0
diagnostic=$(bash "${BASH_SOURCE[0]}" false 2>&1) || failure_status=$?
[[ $failure_status -ne 0 && $diagnostic == *"FAIL false at ${BASH_SOURCE[0]}:"* ]]
[[ $diagnostic =~ :[0-9]+$ ]]
}
TESTS=(
updates_one_record_and_preserves_ttl
unchanged_record_is_not_written
dry_run_reports_change_without_writing
dry_run_reports_unchanged_records
dry_run_supports_the_zone_apex
dry_run_refuses_missing_or_multiple_records
supports_the_zone_apex
supports_nested_names_and_multilabel_zones
refuses_missing_records
refuses_multiple_records
rejects_invalid_arguments_before_network_access
rejects_invalid_credentials_without_echoing_them
rejects_missing_credentials
rejects_invalid_public_addresses
rejects_invalid_record_lists
rejects_unexpected_record_fields
stops_on_http_failures
rejects_unsuccessful_or_malformed_api_responses
keeps_credentials_out_of_arguments_and_logs
uses_bounded_requests_without_redirects
reports_test_failure_location
)
# Each test gets its own process: a failed assertion stops that test immediately.
if [[ $# -eq 1 ]]; then
test_name=$1
trap 'printf "FAIL %s at %s:%s\n" "$test_name" "${BASH_SOURCE[0]}" "$LINENO" >&2' ERR
setup
"$1"
exit
fi
failures=0
for test in "${TESTS[@]}"; do
if bash "${BASH_SOURCE[0]}" "$test"; then
printf 'PASS %s\n' "$test"
else
printf 'FAIL %s\n' "$test" >&2
failures=$((failures + 1))
fi
done
printf '%s tests, %s failures\n' "${#TESTS[@]}" "$failures"
[[ $failures -eq 0 ]]
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
set -euo pipefail
API_URL=https://api-ipv4.porkbun.com/api/json/v3
CREDENTIALS_FILE=/run/secrets/porkbun.json
fail() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
valid_hostname() {
local label='[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?'
[[ ${#1} -le 253 && $1 =~ ^$label(\.$label)*$ ]]
}
# 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 \
--connect-timeout 10 --max-time 30 \
--header 'Content-Type: application/json' \
--data-binary @- "${API_URL}${path}") || fail "Request failed: $path"
# 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"
}
main() {
local dry_run=false
if [[ ${1:-} == --dry-run ]]; then
dry_run=true
shift
fi
[[ $# -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
if [[ -n $name ]]; then
valid_hostname "$name" || fail 'Invalid record name; use a relative DNS name.'
target="$name.$domain"
fi
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"
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 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")
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"
return
fi
if [[ $dry_run == true ]]; then
printf 'Dry run: would update %s A %s -> %s\n' "$target" "$current_ip" "$public_ip"
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 'Updated: %s A %s -> %s\n' "$target" "$current_ip" "$public_ip"
}
# Allow the same entry point to be exercised with mocked HTTP calls in tests.
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
fi