1#!/bin/sh
2
3self='hetzner_ddns'
4
5# Read variabels from configuration file
6if test -G "/usr/local/etc/$self.conf"; then
7    . "/usr/local/etc/$self.conf"
8else
9    >&2 echo 'unable to read configuration file'
10    exit 78
11fi
12
13# Check dependencies
14if ! command -v curl > /dev/null || \
15   ! command -v awk > /dev/null || \
16   ! command -v jq > /dev/null
17then
18    >&2 echo 'missing dependency'
19    exit 1
20fi
21
22# Check logging support
23if ! touch "/var/log/$self.log";
24then
25    >&2 echo 'unable to open logfile'
26    exit 2
27fi
28
29get_zone() {
30    # Get zone ID
31    zone="$(
32        curl "https://dns.hetzner.com/api/v1/zones" \
33            -H "Auth-API-Token: $key" 2>/dev/null | \
34        jq -r '.zones[] | .name + " " + .id' | \
35        awk "\$1==\"$domain\" {print \$2}"
36    )"
37    if [ -z "$zone" ]; then
38        return 1
39    else
40        printf '[%s] Zone for %s: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" \
41            "$domain" "$zone" >> "/var/log/$self.log"
42    fi
43}
44
45get_record() {
46    # Get record IDs
47    if [ -n "$zone" ]; then
48        record_ipv4="$(
49            curl "https://dns.hetzner.com/api/v1/records?zone_id=$zone" \
50                -H "Auth-API-Token: $key" 2>/dev/null | \
51            jq -r '.records[] | .name + " " + .type + " " + .id' | \
52            awk "\$1==\"$1\" && \$2==\"A\" {print \$3}"
53        )"
54        record_ipv6="$(
55            curl "https://dns.hetzner.com/api/v1/records?zone_id=$zone" \
56                -H "Auth-API-Token: $key" 2>/dev/null | \
57            jq -r '.records[] | .name + " " + .type + " " + .id' | \
58            awk "\$1==\"$1\" && \$2==\"AAAA\" {print \$3}"
59        )"
60    fi
61    if [ -z "$record_ipv4" ] && [ -z "$record_ipv6" ]; then
62        return 1
63    else
64        printf '[%s] IPv4 record for %s: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1.$domain" \
65            "${record_ipv4:-(missing)}" >> "/var/log/$self.log"
66        printf '[%s] IPv6 record for %s: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1.$domain" \
67            "${record_ipv6:-(missing)}" >> "/var/log/$self.log"
68    fi
69}
70
71get_records() {
72    # Get all record IDs
73    for n in $records; do
74        if get_record "$n"; then
75            records_ipv4="$records_ipv4$n=$record_ipv4 "
76            records_ipv6="$records_ipv6$n=$record_ipv6 "
77        else
78            printf '[%s] Missing both records for %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" \
79                "$n.$domain" >> "/var/log/$self.log"
80        fi
81    done
82}
83
84get_record_ip_addr() {
85    # Get record's IP address
86    if [ -n "$record_ipv4" ]; then
87        ipv4_rec="$(
88            curl "https://dns.hetzner.com/api/v1/records/$record_ipv4" \
89                -H "Auth-API-Token: $key" 2>/dev/null | \
90            jq -r '.record.value'
91        )"
92    fi
93    if [ -n "$record_ipv6" ]; then
94        ipv6_rec="$(
95            curl "https://dns.hetzner.com/api/v1/records/$record_ipv6" \
96                -H "Auth-API-Token: $key" 2>/dev/null | \
97            jq -r '.record.value'
98        )"
99    fi
100    if [ -z "$ipv4_rec" ] && [ -z "$ipv6_rec" ]; then
101        return 1
102    fi
103}
104
105get_my_ip_addr() {
106    # Get current public IP address
107    ipv4_cur="$(
108        curl 'http://ipv4.whatismyip.akamai.com/' 2>/dev/null
109    )"
110    ipv6_cur="$(
111        curl 'http://ipv6.whatismyip.akamai.com/' 2>/dev/null
112    )"
113    if [ -z "$ipv4_cur" ] && [ -z "$ipv6_cur" ]; then
114        return 1
115    fi
116}
117
118set_record() {
119    # Update record if IP address has changed
120    if [ -n "$record_ipv4" ] && [ -n "$ipv4_cur" ] && [ "$ipv4_cur" != "$ipv4_rec" ]; then
121        curl -X "PUT" "https://dns.hetzner.com/api/v1/records/$record_ipv4" \
122            -H 'Content-Type: application/json' \
123            -H "Auth-API-Token: $key" \
124            -d "{
125            \"value\": \"$ipv4_cur\",
126            \"ttl\": $interval,
127            \"type\": \"A\",
128            \"name\": \"$n\",
129            \"zone_id\": \"$zone\"
130            }" 1>/dev/null 2>/dev/null &&
131        printf "[%s] Update IPv4 for %s: %s => %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" \
132            "$n.$domain" "$ipv4_rec" "$ipv4_cur" >> "/var/log/$self.log"
133    fi
134    if [ -n "$record_ipv6" ] && [ -n "$ipv6_cur" ] && [ "$ipv6_cur" != "$ipv6_rec" ]; then
135        curl -X "PUT" "https://dns.hetzner.com/api/v1/records/$record_ipv6" \
136            -H 'Content-Type: application/json' \
137            -H "Auth-API-Token: $key" \
138            -d "{
139            \"value\": \"$ipv6_cur\",
140            \"ttl\": $interval,
141            \"type\": \"AAAA\",
142            \"name\": \"$n\",
143            \"zone_id\": \"$zone\"
144            }" 1>/dev/null 2>/dev/null &&
145        printf "[%s] Update IPv6 for %s: %s => %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" \
146            "$n.$domain" "$ipv6_rec" "$ipv6_cur" >> "/var/log/$self.log"
147    fi
148}
149
150pick_record() {
151    # Get record ID from array
152    echo "$2" | \
153    awk "{
154        for(i=1;i<=NF;i++){
155            n=\$i;gsub(/=.*/,\"\",n);
156            r=\$i;gsub(/.*=/,\"\",r);
157            if(n==\"$1\"){
158                print r;break
159            }
160        }}"
161}
162
163set_records() {
164    # Get my public IP address
165    if get_my_ip_addr; then
166        # Update all records if possible
167        for n in $records; do
168            record_ipv4="$(pick_record "$n" "$records_ipv4")"
169            record_ipv6="$(pick_record "$n" "$records_ipv6")"
170            if [ -n "$record_ipv4" ] || [ -n "$record_ipv6" ]; then
171                get_record_ip_addr && set_record
172            fi
173        done
174    fi
175}
176
177run_ddns() {
178    printf '[%s] Started Hetzner DDNS daemon\n' "$(date '+%Y-%m-%d %H:%M:%S')" \
179                >> "/var/log/$self.log"
180
181    while ! get_zone || ! get_records; do
182        sleep $((interval/2+1))
183    done
184
185    while true; do
186        set_records
187        sleep "$interval"
188    done
189}
190
191if [ "$1" = '--daemon' ]; then
192    # Deamonize and write PID to file
193    if touch "/var/run/$self.pid";
194    then
195        run_ddns &
196        echo $! > "/var/run/$self.pid"
197    else
198        >&2 echo 'unable to daemonize'
199        exit 2
200    fi
201else
202    # Run in foreground
203    run_ddns
204fi
205