aboutsummaryrefslogtreecommitdiffstats
path: root/templates/ingress/usr_local_bin_hetzner_ddns.sh.j2
blob: 3b23ea5009ca6362e8b562f0955eaf5b08a19b9b (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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
#!/bin/sh
#
# Hetzner DNS record updater (one-shot for cron)
#
# Adds named CLI args:
#   --zone-id        --record-id     --record-name
#   --record-type    --ttl           --iface-cmd
#
# Env overrides (with sensible defaults):
#   API_TOKEN_FILE   (default: /usr/local/etc/hetzner_auth)
#   API_BASE         (default: https://dns.hetzner.com/api/v1)
#
# Exit codes:
#   0 = Updated or already up-to-date
#   1 = Transient issue (no local IP, API/network)
#   2 = Config error (bad/missing args, token)
#
# Requirements: curl, awk

set -eu

# ---------------------------- Defaults ---------------------------------------
# Env-overridable:
: "${API_TOKEN_FILE:=/usr/local/etc/hetzner_auth}"
: "${API_BASE:=https://dns.hetzner.com/api/v1}"
# -----------------------------------------------------------------------------


usage() {
  cat <<'USAGE' >&2
Usage: hetzner_ddns.sh [OPTIONS]

Options (named):
  --zone-id ID            Hetzner zone ID
  --record-id ID          Record ID to update
  --record-name NAME      Record name (e.g., "host" part)
  --record-type TYPE      Record type (e.g., A, AAAA, TXT)
  --ttl SECONDS           TTL in seconds
  --iface-cmd CMD         Command that prints interface info (for IP discovery)
  -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 DNS API base URL
                          (default: https://dns.hetzner.com/api/v1)

Examples:
  API_TOKEN_FILE=/secret/token \
  ./hetzner_ddns.sh \
    --zone-id ZONE123 --record-id REC456 \
    --record-name example --record-type A \
    --ttl 300 --iface-cmd "ifconfig em0"
USAGE
}

# ----------------------------- Arg parsing -----------------------------------
# Accept both `--key value` and `--key=value`
while [ $# -gt 0 ]; do
  case "$1" in
    --zone-id=*)     ZONE_ID=${1#*=} ;;
    --zone-id)       ZONE_ID=$2; shift ;;
    --record-id=*)   RECORD_ID=${1#*=} ;;
    --record-id)     RECORD_ID=$2; shift ;;
    --record-name=*) RECORD_NAME=${1#*=} ;;
    --record-name)   RECORD_NAME=$2; shift ;;
    --record-type=*) RECORD_TYPE=${1#*=} ;;
    --record-type)   RECORD_TYPE=$2; shift ;;
    --ttl=*)         TTL=${1#*=} ;;
    --ttl)           TTL=$2; shift ;;
    --iface-cmd=*)   IFACE_CMD=${1#*=} ;;
    --iface-cmd)     IFACE_CMD=$2; shift ;;
    -h|--help)       usage; exit 0 ;;
    --)              shift; break ;;
    -*)
      echo "ERROR: Unknown option: $1" >&2
      usage
      exit 2
      ;;
    *)
      echo "ERROR: Unexpected positional argument: $1" >&2
      usage
      exit 2
      ;;
  esac
  shift
done

fail() { echo "ERROR: $*" >&2; exit 2; }
warn() { echo "WARN: $*" >&2; }
info() { echo "INFO: $*"; }

# ----------------------------- Validation ------------------------------------
[ -n "${ZONE_ID}" ]     || fail "Missing --zone-id"
[ -n "${RECORD_ID}" ]   || fail "Missing --record-id"
[ -n "${RECORD_NAME}" ] || fail "Missing --record-name"
[ -n "${RECORD_TYPE}" ] || fail "Missing --record-type"
[ -n "${TTL}" ]         || fail "Missing --ttl"
case "${TTL}" in (*[!0-9]*|'') fail "--ttl must be an integer";; esac
[ -n "${IFACE_CMD}" ]   || fail "Missing --iface-cmd"

# ------------------------------ Auth -----------------------------------------
if [ ! -r "${API_TOKEN_FILE}" ]; then
  echo "ERROR: Token file missing or unreadable: ${API_TOKEN_FILE}" >&2
  exit 2
fi
API_TOKEN="$(cat "${API_TOKEN_FILE}" | tr -d '[:space:]')"
[ -n "${API_TOKEN}" ] || { echo "ERROR: API token is empty" >&2; exit 2; }

# ------------------------------ 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 '{"type":"%s","name":"%s","value":"%s","zone_id":"%s","ttl":%s}' \
              "${RECORD_TYPE}" "${RECORD_NAME}" "${IFACE_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 zone ${ZONE_ID} record ${RECORD_ID} -> ${RECORD_TYPE} ${RECORD_NAME} ${IFACE_IP} with TTL ${TTL}"
    exit 0
    ;;
  4*)
    echo "ERROR: Client error from API (HTTP ${HTTP_CODE}) - check IDs/token/body" >&2
    exit 2
    ;;
  5*)
    warn "Server error from API (HTTP ${HTTP_CODE})"
    exit 1
    ;;
  *)
    warn "Unexpected HTTP status ${HTTP_CODE}"
    exit 1
    ;;
esac