Add update-only Porkbun DDNS container #2
No files matched your search
@@ -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 }}
|
||||
@@ -18,6 +18,7 @@ jobs:
|
||||
- containers-hledger
|
||||
- containers-nvim
|
||||
- containers-playwright
|
||||
- containers-porkbun-ddns
|
||||
- containers-ssh
|
||||
- containers-wg-verify
|
||||
- containers-yq
|
||||
|
||||
@@ -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]
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!update.sh
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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 ]]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user