#!/bin/sh # # Hetzner DNS A record updater (one-shot for cron) # # Intended to be run every minute via cron wrapped in: # logto /var/log/hetzner_ddns /usr/local/bin/hetzner_ddns.sh # # Responsibilities: # 1. Determine current IPv4 address from a specified interface (command). # 2. Fetch existing DNS record value from Hetzner. # 3. If different, issue PUT to update record; otherwise exit quietly. # # Exits: # 0 = Successfully updated or already up-to-date. # 1 = Transient issue (e.g., no local IP yet, API/network error). # 2 = Permanent-ish configuration problem (e.g., missing token). # # Requirements: # - curl # - A file /usr/local/etc/hetzner_auth containing ONLY the API token. # # If you ever need to change the interface command, expose a variable # and adapt IFACE_CMD below in the template. set -eu # ---------------------------- Configuration --------------------------------- API_TOKEN_FILE="/usr/local/etc/hetzner_auth" API_BASE="https://dns.hetzner.com/api/v1" ZONE_ID="{{ hetzner_zone_id }}" RECORD_ID="{{ hetzner_record_id }}" RECORD_NAME="pursotin" RECORD_TYPE="A" TTL="300" IFACE_CMD="jexec ingress ifconfig epw1b" # --------------------------------------------------------------------------- fail() { echo "ERROR: $*" >&2 exit 1 } warn() { echo "WARN: $*" >&2 } info() { echo "INFO: $*" } # Read token 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" # Get current IPv4 from interface output get_local_ip() { # Extract the first 'inet ' IPv4 (ignoring inet6) sh -c "${IFACE_CMD}" 2>/dev/null | awk '/inet[[:space:]]/ {print $2; exit}' } LOCAL_IP="$(get_local_ip || true)" if [ -z "${LOCAL_IP}" ]; then warn "No IPv4 address found via '${IFACE_CMD}' (interface not ready?)" exit 1 fi # Fetch current DNS record value fetch_record_ip() { # We ONLY need the "value" field. Avoid jq dependency; use awk. # Response shape (simplified): # {"record":{"id":"...","value":"198.51.100.7", ...}} curl -sS -H "Auth-API-Token: ${API_TOKEN}" \ "${API_BASE}/records/${RECORD_ID}" \ | awk -F'"' '/"value":"/ {print $4; exit}' } REMOTE_IP="$(fetch_record_ip || true)" if [ -z "${REMOTE_IP}" ]; then warn "Failed to parse remote record value (network/API issue?)" exit 1 fi if [ "${REMOTE_IP}" = "${LOCAL_IP}" ]; then info "No change (${LOCAL_IP})" exit 0 fi # Prepare JSON body (avoid subshell harm by using printf for escaping basics) BODY=$(printf '{"type":"%s","name":"%s","value":"%s","zone_id":"%s","ttl":%s}' \ "${RECORD_TYPE}" "${RECORD_NAME}" "${LOCAL_IP}" "${ZONE_ID}" "${TTL}") HTTP_CODE=$( curl -sS -o /dev/null -w "%{http_code}" -X PUT \ -H "Content-Type: application/json" \ -H "Auth-API-Token: ${API_TOKEN}" \ --data "${BODY}" \ "${API_BASE}/records/${RECORD_ID}" ) case "${HTTP_CODE}" in 200) info "Updated ${RECORD_NAME} ${REMOTE_IP} -> ${LOCAL_IP}" exit 0 ;; 4*) fail "Client error from API (HTTP ${HTTP_CODE}) - check IDs/token/body" ;; 5*) warn "Server error from API (HTTP ${HTTP_CODE})" exit 1 ;; *) warn "Unexpected HTTP status ${HTTP_CODE}" exit 1 ;; esac