blob: 8d2ee7dd758d90c484f20b12e89d0cdd19b983d6 (
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
152
|
#!/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" or "host/AAAA"). The rr-type
selects whether the IPv4 or IPv6 address found
via --iface-cmd is published.
--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"
RR_TYPE="${RR_ID##*/}"
case "${RR_TYPE}" in
A|AAAA) ;;
*) fail "Unsupported rr-type '${RR_TYPE}' in --rr-id (expected .../A or .../AAAA)" ;;
esac
# ------------------------------ 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_ipv4() {
# Extract first IPv4 after 'inet ' (ignore inet6)
sh -c "${IFACE_CMD}" 2>/dev/null | awk '/(^|[[:space:]])inet[[:space:]]/ {print $2; exit}'
}
get_ipv6() {
# Extract first non-link-local IPv6 after 'inet6 ', stripping any %scope-id suffix
sh -c "${IFACE_CMD}" 2>/dev/null | awk '/(^|[[:space:]])inet6[[:space:]]/ {
ip = $2
sub(/%.*/, "", ip)
if (ip !~ /^fe80:/) { print ip; exit }
}'
}
# ------------------------------ Main -----------------------------------------
case "${RR_TYPE}" in
AAAA) IFACE_IP="$(get_ipv6 || true)" ;;
*) IFACE_IP="$(get_ipv4 || true)" ;;
esac
if [ -z "${IFACE_IP}" ]; then
warn "No ${RR_TYPE} 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
|