From d646731e72c2bd09ec6d20af5304130d453a4706 Mon Sep 17 00:00:00 2001 From: eleith Date: Fri, 23 Oct 2026 16:50:33 -0700 Subject: [PATCH] Add update-only Porkbun DDNS container --- .github/workflows/build-porkbun-ddns.yml | 64 +++++ .github/workflows/cleanup.yml | 1 + .woodpecker/build-porkbun-ddns.yml | 75 ++++++ Agents.md | 35 +-- README.md | 3 + porkbun-ddns/.dockerignore | 2 + porkbun-ddns/Dockerfile | 9 + porkbun-ddns/README.md | 59 ++++ porkbun-ddns/tests/update_test.sh | 330 +++++++++++++++++++++++ porkbun-ddns/update.sh | 100 +++++++ 10 files changed, 663 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/build-porkbun-ddns.yml create mode 100644 .woodpecker/build-porkbun-ddns.yml create mode 100644 porkbun-ddns/.dockerignore create mode 100644 porkbun-ddns/Dockerfile create mode 100644 porkbun-ddns/README.md create mode 100644 porkbun-ddns/tests/update_test.sh create mode 100644 porkbun-ddns/update.sh diff --git a/.github/workflows/build-porkbun-ddns.yml b/.github/workflows/build-porkbun-ddns.yml new file mode 100644 index 0000000..b92b659 --- /dev/null +++ b/.github/workflows/build-porkbun-ddns.yml @@ -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 }} diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index 7fd7d6c..85a4b68 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -18,6 +18,7 @@ jobs: - containers-hledger - containers-nvim - containers-playwright + - containers-porkbun-ddns - containers-ssh - containers-wg-verify - containers-yq diff --git a/.woodpecker/build-porkbun-ddns.yml b/.woodpecker/build-porkbun-ddns.yml new file mode 100644 index 0000000..00be6a0 --- /dev/null +++ b/.woodpecker/build-porkbun-ddns.yml @@ -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] diff --git a/Agents.md b/Agents.md index e5804ee..88d38b4 100644 --- a/Agents.md +++ b/Agents.md @@ -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? diff --git a/README.md b/README.md index a550c1f..1317cd3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/porkbun-ddns/.dockerignore b/porkbun-ddns/.dockerignore new file mode 100644 index 0000000..38fea28 --- /dev/null +++ b/porkbun-ddns/.dockerignore @@ -0,0 +1,2 @@ +* +!update.sh diff --git a/porkbun-ddns/Dockerfile b/porkbun-ddns/Dockerfile new file mode 100644 index 0000000..d8ac755 --- /dev/null +++ b/porkbun-ddns/Dockerfile @@ -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"] diff --git a/porkbun-ddns/README.md b/porkbun-ddns/README.md new file mode 100644 index 0000000..e77e605 --- /dev/null +++ b/porkbun-ddns/README.md @@ -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 diff --git a/porkbun-ddns/tests/update_test.sh b/porkbun-ddns/tests/update_test.sh new file mode 100644 index 0000000..502ec5c --- /dev/null +++ b/porkbun-ddns/tests/update_test.sh @@ -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 ]] diff --git a/porkbun-ddns/update.sh b/porkbun-ddns/update.sh new file mode 100644 index 0000000..2ef41be --- /dev/null +++ b/porkbun-ddns/update.sh @@ -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] (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 -- 2.54.0