blob: eec12d7edfbd0c27f940605ebfd3e991f559e160 (
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
|
#!/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}"
# -----------------------------------------------------------------------------
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)
-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 ;;
-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"}]}' \
"${IFACE_IP}" "$(ts)")
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/update_records"
)
case "${HTTP_CODE}" in
200)
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
|