diff options
| author | Jan Tuomi <jan@jantuomi.fi> | 2025-10-06 09:55:22 +0300 |
|---|---|---|
| committer | Jan Tuomi <jan@jantuomi.fi> | 2025-10-06 10:42:16 +0300 |
| commit | 5adcc04e4c3441de8258b29216411bb53a709c0f (patch) | |
| tree | 5ccc8ad26eb5f632f79ccdc6f8d0190190ccde07 | |
| parent | ae9be6373428471dd4c95f64741b990638c272c0 (diff) | |
Work on Hetzner DDNS
| -rw-r--r-- | tasks/jail_ingress.yml | 29 | ||||
| -rw-r--r-- | tasks/network.yml | 16 | ||||
| -rw-r--r-- | templates/etc_crontab.j2 | 4 | ||||
| -rw-r--r-- | templates/usr_local_bin_do_dyndns.sh.j2 | 42 | ||||
| -rw-r--r-- | templates/usr_local_bin_hetzner_ddns.sh.j2 | 153 | ||||
| -rw-r--r-- | templates/usr_local_etc_rc.d_hetzner_ddns.j2 | 48 |
6 files changed, 121 insertions, 171 deletions
diff --git a/tasks/jail_ingress.yml b/tasks/jail_ingress.yml index d6db2f5..52af116 100644 --- a/tasks/jail_ingress.yml +++ b/tasks/jail_ingress.yml @@ -64,32 +64,3 @@ service -j ingress nginx enable service -j ingress nginx onestart when: ingress_nginx_enabled.rc != 0 - -- name: Configure hetzner-ddns - template: - src: usr_local_etc_rc.d_hetzner_ddns.j2 - dest: /usr/local/jails/containers/ingress/usr/local/etc/rc.d/hetzner_ddns - owner: root - group: wheel - mode: "0755" - -- name: Install hetzner-ddns script - template: - src: usr_local_bin_hetzner_ddns.sh.j2 - dest: /usr/local/jails/containers/ingress/usr/local/bin/hetzner_ddns.sh - owner: root - group: wheel - mode: "0755" - -- name: Set up hetzner_auth - template: - src: usr_local_etc_hetzner_auth.j2 - dest: /usr/local/jails/containers/ingress/usr/local/etc/hetzner_auth - owner: root - group: wheel - mode: "0600" - -- name: Restart hetzner-ddns - shell: | - service -j ingress hetzner_ddns enable - service -j ingress hetzner_ddns restart diff --git a/tasks/network.yml b/tasks/network.yml index bf22044..0e4a722 100644 --- a/tasks/network.yml +++ b/tasks/network.yml @@ -66,3 +66,19 @@ - name: Reload pf rules shell: pfctl -f /etc/pf.conf when: pf_conf.changed + +- name: Install hetzner_ddns.sh + template: + src: usr_local_bin_hetzner_ddns.sh.j2 + dest: /usr/local/bin/hetzner_ddns.sh + owner: root + group: wheel + mode: "0755" + +- name: Set up hetzner_auth + template: + src: usr_local_etc_hetzner_auth.j2 + dest: /usr/local/etc/hetzner_auth + owner: root + group: wheel + mode: "0600" diff --git a/templates/etc_crontab.j2 b/templates/etc_crontab.j2 index 62b1b04..debc65e 100644 --- a/templates/etc_crontab.j2 +++ b/templates/etc_crontab.j2 @@ -21,8 +21,8 @@ PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin # UTC time. See adjkerntz(8) for details. 1,31 0-5 * * * root adjkerntz -a -# Update dynamic DNS -*/1 * * * * root logto -e /var/log/do_dyndns.err /usr/local/bin/do_dyndns.sh +# Update dynamic DNS (Hetzner) +* * * * * root logto /var/log/hetzner_ddns /usr/local/bin/hetzner_ddns.sh # Take snapshots every day at 3:05 AM 5 3 * * * root logto /var/log/backup /usr/local/bin/backup snapshot prune-local diff --git a/templates/usr_local_bin_do_dyndns.sh.j2 b/templates/usr_local_bin_do_dyndns.sh.j2 deleted file mode 100644 index f23d635..0000000 --- a/templates/usr_local_bin_do_dyndns.sh.j2 +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/sh -set -eu - -DOMAIN="jan.systems" -HOSTNAME="pursotin" -AUTH_FILE="/usr/local/etc/do_dyndns_auth" -IF4="$(jexec ingress ifconfig epw1b)" - -# END OF CONFIG - -info() { - echo "$(date) [INFO]" $@ -} - -err() { - >&2 echo "$(date) [ERROR]" $@ -} - -if [ ! -r "$AUTH_FILE" ]; then - err "Auth file '$AUTH_FILE' not readable" - exit 1 -fi - -IP4=$(echo "$IF4" | awk '/inet / { print $2 }' | head -n1) -DIGITALOCEAN_TOKEN=$(awk '{$1=$1; print}' "$AUTH_FILE") - -info "Updating A record for hostname $HOSTNAME in domain $DOMAIN → $IP4" - -RECORD_ID=$(curl -s -H "Authorization: Bearer $DIGITALOCEAN_TOKEN" \ -"https://api.digitalocean.com/v2/domains/$DOMAIN/records?per_page=999999" | -jq -r ".domain_records[] | select(.type==\"A\" and .name==\"$HOSTNAME\") | .id") - -if [ -n "$RECORD_ID" ]; then - curl -s -X PUT \ - -H "Authorization: Bearer $DIGITALOCEAN_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"data\":\"$IP4\"}" \ - "https://api.digitalocean.com/v2/domains/$DOMAIN/records/$RECORD_ID" > /dev/null - info "Record $RECORD_ID updated" -else - err "No A record found for hostname \"$HOSTNAME\" in domain \"$DOMAIN\". Skipping." -fi diff --git a/templates/usr_local_bin_hetzner_ddns.sh.j2 b/templates/usr_local_bin_hetzner_ddns.sh.j2 index aee1016..7fed440 100644 --- a/templates/usr_local_bin_hetzner_ddns.sh.j2 +++ b/templates/usr_local_bin_hetzner_ddns.sh.j2 @@ -1,66 +1,119 @@ #!/bin/sh -# Minimal Hetzner DNS updater for pfSense/FreeBSD -# - Always updates on startup (no pre-check) -# - Then polls and only updates when IP changes -# - Keeps last IP only in memory (no files) +# +# Hetzner DNS A record updater (one-shot for cron) +# +# Intended to be run every minute via cron wrapped in: +# logto /var/log/hetzner_ddns /usr/local/bin/hetzner_ddns.sh +# +# Responsibilities: +# 1. Determine current IPv4 address from a specified interface (command). +# 2. Fetch existing DNS record value from Hetzner. +# 3. If different, issue PUT to update record; otherwise exit quietly. +# +# Exits: +# 0 = Successfully updated or already up-to-date. +# 1 = Transient issue (e.g., no local IP yet, API/network error). +# 2 = Permanent-ish configuration problem (e.g., missing token). +# +# Requirements: +# - curl +# - A file /usr/local/etc/hetzner_auth containing ONLY the API token. +# +# If you ever need to change the interface command, expose a variable +# and adapt IFACE_CMD below in the template. -# --- CONFIG --------------------------------------------------------------- -API_TOKEN="$(cat /usr/local/etc/hetzner_auth)" +set -eu + +# ---------------------------- Configuration --------------------------------- +API_TOKEN_FILE="/usr/local/etc/hetzner_auth" +API_BASE="https://dns.hetzner.com/api/v1" ZONE_ID="{{ hetzner_zone_id }}" RECORD_ID="{{ hetzner_record_id }}" -RECORD_NAME=pursotin +RECORD_NAME="pursotin" RECORD_TYPE="A" -IFACE_CMD="ifconfig epw1b" - -POLL_INTERVAL=60 -API_BASE="https://dns.hetzner.com/api/v1" TTL="300" -# -------------------------------------------------------------------------- +IFACE_CMD="jexec ingress ifconfig epw1b" +# --------------------------------------------------------------------------- -get_ip() { - # Expect FreeBSD-style ifconfig output; grab first IPv4 addr - # Example: 'inet 192.0.2.3 ...' - sh -c "$IFACE_CMD" 2>/dev/null | awk '/inet[[:space:]]/ {print $2; exit}' +fail() { + echo "ERROR: $*" >&2 + exit 1 } -update_record() { - ip="$1" - body=$(printf '{"type":"%s","name":"%s","value":"%s","zone_id":"%s","ttl":%s}' \ - "$RECORD_TYPE" "$RECORD_NAME" "$ip" "$ZONE_ID" "$TTL") +warn() { + echo "WARN: $*" >&2 +} - 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}" - ) +info() { + echo "INFO: $*" +} - [ "$http_code" = "200" ] || { - echo "$(date -u +"%F %T") update failed (HTTP $http_code)" >&2 - return 1 - } +# Read token +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" - echo "$(date -u +"%F %T") updated ${RECORD_NAME} to ${ip}" - return 0 +# Get current IPv4 from interface output +get_local_ip() { + # Extract the first 'inet ' IPv4 (ignoring inet6) + sh -c "${IFACE_CMD}" 2>/dev/null | awk '/inet[[:space:]]/ {print $2; exit}' } -# --- Startup: always update once (no check) -------------------------------- -last_ip="" -ip="$(get_ip)" -if [ -n "$ip" ]; then - update_record "$ip" && last_ip="$ip" -else - echo "$(date -u +"%F %T") no IPv4 from: ${IFACE_CMD}; will retry..." >&2 +LOCAL_IP="$(get_local_ip || true)" +if [ -z "${LOCAL_IP}" ]; then + warn "No IPv4 address found via '${IFACE_CMD}' (interface not ready?)" + exit 1 fi -# --- Poll loop: update only on change -------------------------------------- -while :; do - ip="$(get_ip)" - if [ -n "$ip" ] && [ "$ip" != "$last_ip" ]; then - if update_record "$ip"; then - last_ip="$ip" - fi - fi - sleep "$POLL_INTERVAL" -done +# Fetch current DNS record value +fetch_record_ip() { + # We ONLY need the "value" field. Avoid jq dependency; use awk. + # Response shape (simplified): + # {"record":{"id":"...","value":"198.51.100.7", ...}} + curl -sS -H "Auth-API-Token: ${API_TOKEN}" \ + "${API_BASE}/records/${RECORD_ID}" \ + | awk -F'"' '/"value":"/ {print $4; exit}' +} + +REMOTE_IP="$(fetch_record_ip || true)" +if [ -z "${REMOTE_IP}" ]; then + warn "Failed to parse remote record value (network/API issue?)" + exit 1 +fi + +if [ "${REMOTE_IP}" = "${LOCAL_IP}" ]; then + info "No change (${LOCAL_IP})" + exit 0 +fi + +# Prepare JSON body (avoid subshell harm by using printf for escaping basics) +BODY=$(printf '{"type":"%s","name":"%s","value":"%s","zone_id":"%s","ttl":%s}' \ + "${RECORD_TYPE}" "${RECORD_NAME}" "${LOCAL_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 ${RECORD_NAME} ${REMOTE_IP} -> ${LOCAL_IP}" + exit 0 + ;; + 4*) + fail "Client error from API (HTTP ${HTTP_CODE}) - check IDs/token/body" + ;; + 5*) + warn "Server error from API (HTTP ${HTTP_CODE})" + exit 1 + ;; + *) + warn "Unexpected HTTP status ${HTTP_CODE}" + exit 1 + ;; +esac diff --git a/templates/usr_local_etc_rc.d_hetzner_ddns.j2 b/templates/usr_local_etc_rc.d_hetzner_ddns.j2 deleted file mode 100644 index ccbbb8c..0000000 --- a/templates/usr_local_etc_rc.d_hetzner_ddns.j2 +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/sh -# -# PROVIDE: hetzner_ddns -# REQUIRE: NETWORKING -# KEYWORD: shutdown - -# Enable via: -# hetzner_ddns_enable="YES" -# -# (pfSense: System -> Advanced -> System Tunables or /etc/rc.conf) - -. /etc/rc.subr - -name="hetzner_ddns" -rcvar=hetzner_ddns_enable - -load_rc_config $name - -: ${hetzner_ddns_enable:="NO"} -: ${hetzner_ddns_user:="root"} -: ${hetzner_ddns_command:="/usr/local/bin/hetzner-ddns.sh"} - -start_cmd="${name}_start" -stop_cmd="${name}_stop" -status_cmd="${name}_status" - -hetzner_ddns_start() { - echo "Starting ${name}..." - /usr/sbin/daemon -t "${name}" -u "${hetzner_ddns_user}" ${hetzner_ddns_command} -} - -hetzner_ddns_stop() { - echo "Stopping ${name}..." - # First try by daemon title, then by command as fallback - pkill -f "^daemon: ${name}$" || pkill -f "${hetzner_ddns_command}" -} - -hetzner_ddns_status() { - if pgrep -f "^daemon: ${name}$" >/dev/null 2>&1 || pgrep -f "${hetzner_ddns_command}" >/dev/null 2>&1; then - echo "${name} is running" - return 0 - else - echo "${name} is not running" - return 1 - fi -} - -run_rc_command "$1" |
