#!/bin/sh # # Hetzner DNS record updater (one-shot for cron) # Requirements: curl, awk set -eu # ---------------------------- Defaults --------------------------------------- # Env-overridable: : "${API_TOKEN_FILE:=/usr/local/etc/hetzner_auth}" : "${API_BASE:=https://api.hetzner.cloud/v1}" # ----------------------------------------------------------------------------- # Defaults TTL=300 usage() { cat <<'USAGE' >&2 Usage: hetzner_ddns.sh [OPTIONS] Options (named): --zone NAME_OR_ID Hetzner Zone name or ID (primary mode only) --rr-id ID RRSet identifier in the form "rr-name/rr-type" (e.g., "host/A") --iface-cmd CMD Command that prints interface info (for IP discovery) --ttl TTL Time-to-live of the record (optional) -h, --help Show this help Environment: API_TOKEN_FILE Path to file containing ONLY the API token (default: /usr/local/etc/hetzner_auth) API_BASE Hetzner Cloud DNS API base URL (default: https://api.hetzner.cloud/v1) Examples: API_TOKEN_FILE=/secret/token \ ./hetzner_ddns.sh \ --zone example.com --rr-name host --rr-type A \ --iface-cmd "ifconfig em0" USAGE } ts() { date +"%Y-%m-%dT%H:%M:%S%z"; } fail() { echo "$(ts) ERROR: $*" >&2; exit 2; } error() { echo "$(ts) ERROR: $*" >&2; } warn() { echo "$(ts) WARN: $*" >&2; } info() { echo "$(ts) INFO: $*"; } # ----------------------------- Arg parsing ----------------------------------- # Accept both `--key value` and `--key=value` while [ $# -gt 0 ]; do case "$1" in --zone=*) ZONE=${1#*=} ;; --zone) ZONE=$2; shift ;; --rr-id=*) RR_ID=${1#*=} ;; --rr-id) RR_ID=$2; shift ;; --iface-cmd=*) IFACE_CMD=${1#*=} ;; --iface-cmd) IFACE_CMD=$2; shift ;; --ttl=*) TTL=${1#*=} ;; --ttl) TTL=$2; shift ;; -h|--help) usage; exit 0 ;; --) shift; break ;; -*) error "Unknown option: $1" usage exit 2 ;; *) error "Unexpected positional argument: $1" usage exit 2 ;; esac shift done # ----------------------------- Validation ------------------------------------ [ -n "${ZONE:-}" ] || fail "Missing --zone" [ -n "${RR_ID:-}" ] || fail "Missing --rr-id" [ -n "${IFACE_CMD:-}" ] || fail "Missing --iface-cmd" # ------------------------------ Auth ----------------------------------------- if [ ! -r "${API_TOKEN_FILE}" ]; then fail "Token file missing or unreadable: ${API_TOKEN_FILE}" fi API_TOKEN="$(cat "${API_TOKEN_FILE}" | tr -d '[:space:]')" [ -n "${API_TOKEN}" ] || fail "API token is empty" # ------------------------------ Helpers -------------------------------------- get_ip() { # Extract first IPv4 after 'inet ' (ignore inet6) sh -c "${IFACE_CMD}" 2>/dev/null | awk '/(^|[[:space:]])inet[[:space:]]/ {print $2; exit}' } # ------------------------------ Main ----------------------------------------- IFACE_IP="$(get_ip || true)" if [ -z "${IFACE_IP}" ]; then warn "No IPv4 address found via '${IFACE_CMD}' (interface not ready?)" exit 1 fi BODY=$(printf '{"records":[{"value":"%s","comment":"Updated by hetzner_ddns.sh at %s","ttl":"%s"}]}' \ "${IFACE_IP}" "$(ts)" "${TTL}") HTTP_CODE=$( curl -sS -o /dev/null -w "%{http_code}" -X POST \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${API_TOKEN}" \ --data "${BODY}" \ "${API_BASE}/zones/${ZONE}/rrsets/${RR_ID}/actions/set_records" ) case "${HTTP_CODE}" in 2*) info "Updated zone ${ZONE} RRSet ${RR_ID} -> ${IFACE_IP}" exit 0 ;; 4*) error "Client error from API (HTTP ${HTTP_CODE}) - check zone/rr params/token/body" exit 2 ;; 5*) warn "Server error from API (HTTP ${HTTP_CODE})" exit 1 ;; *) warn "Unexpected HTTP status ${HTTP_CODE}" exit 1 ;; esac