aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJan Tuomi <jan@jantuomi.fi>2025-10-06 09:55:22 +0300
committerJan Tuomi <jan@jantuomi.fi>2025-10-06 10:42:16 +0300
commit5adcc04e4c3441de8258b29216411bb53a709c0f (patch)
tree5ccc8ad26eb5f632f79ccdc6f8d0190190ccde07
parentae9be6373428471dd4c95f64741b990638c272c0 (diff)
Work on Hetzner DDNS
-rw-r--r--tasks/jail_ingress.yml29
-rw-r--r--tasks/network.yml16
-rw-r--r--templates/etc_crontab.j24
-rw-r--r--templates/usr_local_bin_do_dyndns.sh.j242
-rw-r--r--templates/usr_local_bin_hetzner_ddns.sh.j2153
-rw-r--r--templates/usr_local_etc_rc.d_hetzner_ddns.j248
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"