diff --git a/cf-ddns.sh b/cf-ddns.sh new file mode 100644 index 0000000..e7cccaa --- /dev/null +++ b/cf-ddns.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="/etc/cf-ddns/cf-ddns.env" +STATE_DIR="/var/lib/cf-ddns" +API="https://api.cloudflare.com/client/v4" + +log() { logger -t cf-ddns "$*"; echo "[cf-ddns] $*"; } +fail() { log "ERROR: $*"; exit 1; } + +# --- Load env --- +[[ -f "$ENV_FILE" ]] || fail "Missing $ENV_FILE" +# shellcheck disable=SC1090 +source "$ENV_FILE" +: "${CF_API_TOKEN:?CF_API_TOKEN required in env}" +: "${CF_PROXIED:=false}" +: "${CF_TTL:=120}" + +# --- Derive zone/record from system hostname --- +CF_RECORD_NAME="$(hostname -f 2>/dev/null || true)" +CF_ZONE_NAME="$(hostname -d 2>/dev/null || true)" + +[[ -n "$CF_RECORD_NAME" ]] || fail "hostname -f returned empty" +[[ -n "$CF_ZONE_NAME" ]] || fail "hostname -d returned empty (set a DNS/search domain)" + +if [[ "$CF_RECORD_NAME" != *".${CF_ZONE_NAME}" ]]; then + log "Warning: record '$CF_RECORD_NAME' does not end with zone '$CF_ZONE_NAME' (continuing anyway)" +fi + +# Safe filename for state +safe_name="$(echo "$CF_RECORD_NAME" | tr '/:' '__')" +LAST_IP_FILE="${STATE_DIR}/last_ipv4_${safe_name}" + +mkdir -p "$STATE_DIR" + +# --- Detect local IPv4 on default route interface --- +detect_ip() { + local iface ip + + # Prefer ip-route default; fallback to ip route get + iface="$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}')" + if [[ -z "$iface" ]]; then + iface="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}')" + fi + [[ -n "$iface" ]] || fail "Could not detect default route interface" + + ip="$(ip -4 addr show dev "$iface" | awk '/inet / {print $2}' | cut -d/ -f1 | head -n1)" + [[ -n "$ip" ]] || fail "No IPv4 found on default interface '$iface'" + + echo "$ip" +} + +current_ip="$(detect_ip)" +previous_ip="$( [[ -f "$LAST_IP_FILE" ]] && cat "$LAST_IP_FILE" || echo "" )" + +if [[ "$current_ip" == "$previous_ip" && -n "$current_ip" ]]; then + log "IP unchanged ($current_ip); no update needed for ${CF_RECORD_NAME}." + exit 0 +fi + +# --- Cloudflare helpers --- +urlenc() { local s="$1"; s="${s// /%20}"; s="${s//\//%2F}"; echo -n "$s"; } +hdr=(-H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json") + +# 1) Get Zone ID +zone_q="name=$(urlenc "$CF_ZONE_NAME")" +zone_id="$(curl -fsS "${hdr[@]}" "${API}/zones?${zone_q}" \ + | sed -n 's/.*"id":"\([^"]*\)".*"name":"'"$CF_ZONE_NAME"'".*/\1/p' | head -n1)" +[[ -n "$zone_id" ]] || fail "Could not find Zone ID for ${CF_ZONE_NAME}" + +# 2) Lookup existing A record by name +rec_q="type=A&name=$(urlenc "$CF_RECORD_NAME")" +record_json="$(curl -fsS "${hdr[@]}" "${API}/zones/${zone_id}/dns_records?${rec_q}")" +record_id="$(echo "$record_json" | sed -n 's/.*"id":"\([^"]*\)".*"type":"A".*"name":"'"$CF_RECORD_NAME"'".*/\1/p' | head -n1)" + +# 3) Create payload + create/update +json_payload() { + cat < ${current_ip}" + curl -fsS -X POST "${hdr[@]}" \ + --data "$(json_payload)" \ + "${API}/zones/${zone_id}/dns_records" >/dev/null + log "Created." +else + log "Updating A ${CF_RECORD_NAME} -> ${current_ip}" + curl -fsS -X PUT "${hdr[@]}" \ + --data "$(json_payload)" \ + "${API}/zones/${zone_id}/dns_records/${record_id}" >/dev/null + log "Updated." +fi + +echo "$current_ip" > "$LAST_IP_FILE" \ No newline at end of file