From a4d8165dc2de3a0c6d5958a39041258d0c4e665a Mon Sep 17 00:00:00 2001 From: Nikholas Pcenicni <82239765+nikpcenicni@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:09:18 -0400 Subject: [PATCH] Add Cloudflare DDNS updater scripts and systemd configurations --- dyndns/.env.example | 0 dyndns/cf-ddns.sh | 76 +++++++++++++++++++++++++++++++++ dyndns/deploy.sh | 19 +++++++++ dyndns/git-sync.sh | 29 +++++++++++++ dyndns/systemd/cf-ddns.service | 9 ++++ dyndns/systemd/cf-ddns.timer | 10 +++++ dyndns/systemd/git-sync.service | 9 ++++ dyndns/systemd/git-sync.timer | 10 +++++ 8 files changed, 162 insertions(+) create mode 100644 dyndns/.env.example create mode 100644 dyndns/cf-ddns.sh create mode 100644 dyndns/deploy.sh create mode 100644 dyndns/git-sync.sh create mode 100644 dyndns/systemd/cf-ddns.service create mode 100644 dyndns/systemd/cf-ddns.timer create mode 100644 dyndns/systemd/git-sync.service create mode 100644 dyndns/systemd/git-sync.timer diff --git a/dyndns/.env.example b/dyndns/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/dyndns/cf-ddns.sh b/dyndns/cf-ddns.sh new file mode 100644 index 0000000..e1e9da4 --- /dev/null +++ b/dyndns/cf-ddns.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# cf-ddns.sh - Cloudflare DDNS updater +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "${DIR}/.env" ]; then + # shellcheck disable=SC1091 + source "${DIR}/.env" +else + echo "$(date -Iseconds) ERROR: missing ${DIR}/.env (create from .env.example)" >&2 + exit 1 +fi + +: "${CF_API_TOKEN:?need CF_API_TOKEN in .env}" +: "${ZONE:?need ZONE in .env}" +: "${RECORDS:?need RECORDS in .env}" +: "${IP_URL:=https://ipv4.icanhazip.com}" +: "${PROXIED:=false}" + +AUTH_HEADER="Authorization: Bearer ${CF_API_TOKEN}" +CT_HEADER="Content-Type: application/json" + +log() { echo "$(date -Iseconds) $*"; } + +IP=$(curl -fsS "${IP_URL}" | tr -d '[:space:]') +if [[ -z "${IP}" ]]; then + log "ERROR: could not determine public IP" + exit 1 +fi + +ZONE_ID=$(curl -fsS -X GET "https://api.cloudflare.com/client/v4/zones?name=${ZONE}" \ + -H "${AUTH_HEADER}" -H "${CT_HEADER}" | jq -r '.result[0].id // empty') + +if [[ -z "${ZONE_ID}" ]]; then + log "ERROR: zone not found: ${ZONE}" + exit 1 +fi + +IFS=',' read -r -a RECORD_ARR <<< "${RECORDS}" +for fullrec in "${RECORD_ARR[@]}"; do + rec=$(echo "${fullrec}" | xargs) + log "Processing ${rec}" + info=$(curl -fsS -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${rec}&type=A" \ + -H "${AUTH_HEADER}" -H "${CT_HEADER}") + + RECORD_ID=$(echo "$info" | jq -r '.result[0].id // empty') + CURRENT_CONTENT=$(echo "$info" | jq -r '.result[0].content // empty') + + if [[ -z "${RECORD_ID}" ]]; then + log "Creating ${rec} -> ${IP}" + create_resp=$(curl -fsS -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \ + -H "${AUTH_HEADER}" -H "${CT_HEADER}" \ + --data "{\"type\":\"A\",\"name\":\"${rec}\",\"content\":\"${IP}\",\"ttl\":1,\"proxied\":${PROXIED}}") + ok=$(echo "$create_resp" | jq -r '.success // false') + if [[ "$ok" != "true" ]]; then + log "ERROR creating ${rec}: ${create_resp}" + else + log "Created ${rec}" + fi + else + if [[ "${CURRENT_CONTENT}" == "${IP}" ]]; then + log "${rec} unchanged (${IP})" + else + log "Updating ${rec} -> ${IP}" + upd_resp=$(curl -fsS -X PUT "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \ + -H "${AUTH_HEADER}" -H "${CT_HEADER}" \ + --data "{\"type\":\"A\",\"name\":\"${rec}\",\"content\":\"${IP}\",\"ttl\":1,\"proxied\":${PROXIED}}") + ok=$(echo "$upd_resp" | jq -r '.success // false') + if [[ "$ok" != "true" ]]; then + log "ERROR updating ${rec}: ${upd_resp}" + else + log "Updated ${rec}" + fi + fi + fi +done \ No newline at end of file diff --git a/dyndns/deploy.sh b/dyndns/deploy.sh new file mode 100644 index 0000000..4277393 --- /dev/null +++ b/dyndns/deploy.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# deploy.sh - copy systemd units and on_boot entry into place, reload systemd, enable timers +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SYSTEMD_DIR="/etc/systemd/system" + +echo "Deploying from ${REPO_DIR}" + +if [[ -d "${REPO_DIR}/systemd" ]]; then + cp -f "${REPO_DIR}/systemd/"* "${SYSTEMD_DIR}/" +fi + +chmod +x "${REPO_DIR}/cf-ddns.sh" +systemctl daemon-reload || true +systemctl enable --now cf-ddns.timer || true +systemctl enable --now git-sync.timer || true + +echo "Deployment complete." \ No newline at end of file diff --git a/dyndns/git-sync.sh b/dyndns/git-sync.sh new file mode 100644 index 0000000..b64a881 --- /dev/null +++ b/dyndns/git-sync.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# git-sync.sh - pull changes and run deploy.sh if updated +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${REPO_DIR}" + +git fetch --all --prune + +UPDATED=0 +if git rev-parse --abbrev-ref --symbolic-full-name @{u} >/dev/null 2>&1; then + LOCAL="$(git rev-parse @)" + REMOTE="$(git rev-parse @{u})" + if [ "${LOCAL}" != "${REMOTE}" ]; then + UPDATED=1 + fi +else + out="$(git pull --rebase 2>&1 || true)" + if ! echo "${out}" | grep -q "Already up"; then + UPDATED=1 + fi +fi + +if [ "${UPDATED}" -eq 1 ]; then + echo "$(date -Iseconds) Repo updated -> running deploy.sh" + "${REPO_DIR}/deploy.sh" +else + echo "$(date -Iseconds) Repo unchanged" +fi \ No newline at end of file diff --git a/dyndns/systemd/cf-ddns.service b/dyndns/systemd/cf-ddns.service new file mode 100644 index 0000000..9257ab2 --- /dev/null +++ b/dyndns/systemd/cf-ddns.service @@ -0,0 +1,9 @@ +[Unit] +Description=Cloudflare DDNS updater +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/opt/cloudflare-ddns/cf-ddns.sh +WorkingDirectory=/opt/cloudflare-ddns \ No newline at end of file diff --git a/dyndns/systemd/cf-ddns.timer b/dyndns/systemd/cf-ddns.timer new file mode 100644 index 0000000..823a0f9 --- /dev/null +++ b/dyndns/systemd/cf-ddns.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run Cloudflare DDNS every 5 minutes + +[Timer] +OnBootSec=1min +OnUnitActiveSec=5min +Persistent=true + +[Install] +WantedBy=timers.target \ No newline at end of file diff --git a/dyndns/systemd/git-sync.service b/dyndns/systemd/git-sync.service new file mode 100644 index 0000000..e3c82a4 --- /dev/null +++ b/dyndns/systemd/git-sync.service @@ -0,0 +1,9 @@ +[Unit] +Description=Git sync for cloudflare-ddns repo +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=/opt/cloudflare-ddns +ExecStart=/opt/cloudflare-ddns/git-sync.sh \ No newline at end of file diff --git a/dyndns/systemd/git-sync.timer b/dyndns/systemd/git-sync.timer new file mode 100644 index 0000000..5e12be9 --- /dev/null +++ b/dyndns/systemd/git-sync.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run git-sync every 1 minute + +[Timer] +OnBootSec=30s +OnUnitActiveSec=60s +Persistent=true + +[Install] +WantedBy=timers.target \ No newline at end of file