aboutsummaryrefslogtreecommitdiffstats
path: root/templates/ingress/usr_local_bin_hetzner_ddns.sh.j2
blob: a2f4430d1ab4fa4e67503edc82cf9de431ecabe4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
#!/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