diff --git a/net/ddns-scripts/Makefile b/net/ddns-scripts/Makefile index 9caa77d8e2..3acba31fc6 100644 --- a/net/ddns-scripts/Makefile +++ b/net/ddns-scripts/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=ddns-scripts PKG_VERSION:=2.8.3 -PKG_RELEASE:=5 +PKG_RELEASE:=6 PKG_LICENSE:=GPL-2.0 diff --git a/net/ddns-scripts/files/usr/lib/ddns/update_blazingfast_io.sh b/net/ddns-scripts/files/usr/lib/ddns/update_blazingfast_io.sh index 9fa9fb16f6..05063c54f4 100644 --- a/net/ddns-scripts/files/usr/lib/ddns/update_blazingfast_io.sh +++ b/net/ddns-scripts/files/usr/lib/ddns/update_blazingfast_io.sh @@ -9,12 +9,27 @@ # # This script is parsed by dynamic_dns_functions.sh inside send_update() function # +# Features: +# - JWT token caching: tokens are cached to disk for up to 270 seconds and +# reused across update cycles, avoiding repeated login calls that can +# trigger API rate limiting. Token expiry is detected automatically and +# a fresh login is performed transparently. +# - Configurable TTL: record TTL can be set via param_opt (ttl=SECONDS); +# defaults to 300 seconds if not specified. +# - Dual-stack support: A and AAAA records can be updated independently +# using two DDNS service sections with the same hostname. +# - Record type safety: the record type is verified against the configured +# record_id before any update is attempted, preventing accidental +# cross-type updates. +# # using following options from /etc/config/ddns -# option username - Your Blazingfast client area username +# option username - Your Blazingfast client area username (supports @ and +# other special characters) # option password - Your Blazingfast client area password # option domain - Full DNS record name to update, e.g. hostname.yourdomain.com # option param_opt - Space-separated key=value pairs: # service_id=SERVICE_ID zone_id=ZONE_ID record_id=RECORD_ID +# Optional: ttl=SECONDS (default: 300) # # For dual-stack IPv4 + IPv6, create two DDNS service sections: # - one with option use_ipv6 '0' and the A record_id @@ -56,24 +71,27 @@ # - A_RECORD_ID for the A record # - AAAA_RECORD_ID for the AAAA record # +# Custom TTL example (sets record TTL to 60 seconds): +# option param_opt 'service_id=SERVICE_ID zone_id=ZONE_ID record_id=RECORD_ID ttl=60' +# # How to find your service_id, zone_id, record_id: # # 1. Get your token: # TOKEN=$(curl -s -X POST 'https://my.blazingfast.io/api/login' \ -# -d "username=USERNAME" \ -# -d "password=PASSWORD" | grep -o '"token":"[^"]*"' | cut -d'"' -f4) +# --data-urlencode "username=USERNAME" \ +# --data-urlencode "password=PASSWORD" | jsonfilter -e "@.token") # # 2. List services to get service_id: # curl -s 'https://my.blazingfast.io/api/service' \ -# -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +# -H "Authorization: Bearer $TOKEN" | jsonfilter -e "@.services" # # 3. List DNS zones to get zone_id (replace SERVICE_ID): # curl -s 'https://my.blazingfast.io/api/service/SERVICE_ID/dns' \ -# -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +# -H "Authorization: Bearer $TOKEN" | jsonfilter -e "@.zones" # # 4. List records to get record_id values, including both A and AAAA records: # curl -s 'https://my.blazingfast.io/api/service/SERVICE_ID/dns/ZONE_ID' \ -# -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +# -H "Authorization: Bearer $TOKEN" | jsonfilter -e "@.records" # # Then set param_opt to: # service_id=SERVICE_ID zone_id=ZONE_ID record_id=RECORD_ID @@ -83,25 +101,40 @@ . /usr/share/libubox/jshn.sh -# check parameters +# --------------------------------------------------------------------------- +# Parameter validation +# --------------------------------------------------------------------------- +# $CURL_SSL is a framework boolean flag (non-empty = SSL supported); verified +# here to ensure the Blazingfast HTTPS-only API can be reached. [ -z "$CURL_SSL" ] && write_log 14 "Blazingfast communication requires cURL with SSL support. Please install" +# $CURL is the actual binary path, set by dynamic_dns_functions.sh via +# `command -v curl`. Guard explicitly so a misconfigured framework produces +# a clear diagnostic rather than a silent empty-command failure. +[ -z "$CURL" ] && write_log 14 "Cannot find curl binary — check ddns-scripts installation" [ -z "$username" ] && write_log 14 "Service section not configured correctly! Missing 'username'" [ -z "$password" ] && write_log 14 "Service section not configured correctly! Missing 'password'" [ -z "$domain" ] && write_log 14 "Service section not configured correctly! Missing 'domain'" [ "${use_https:-0}" -eq 0 ] && use_https=1 # force HTTPS -# Always use the SSL-capable curl binary because the Blazingfast API is HTTPS-only. -# $CURL may be unset if the framework only detected an SSL-enabled curl. -local __CURLBIN="$CURL_SSL" +# $CURL_SSL is a framework boolean flag (non-empty = SSL supported); the actual +# binary path is always $CURL, set by dynamic_dns_functions.sh to `command -v curl`. +local __CURLBIN="$CURL" -# parse param_opt — expects: service_id=X zone_id=Y record_id=Z -local __SERVICE_ID __ZONE_ID __RECORD_ID +# --------------------------------------------------------------------------- +# Parse param_opt — expects: service_id=X zone_id=Y record_id=Z [ttl=N] +# +# ttl is optional; defaults to 300 seconds if omitted. Exposing it here +# avoids hardcoding and lets users tune propagation vs. API call frequency +# without editing the script. +# --------------------------------------------------------------------------- +local __SERVICE_ID __ZONE_ID __RECORD_ID __TTL if [ -n "$param_opt" ]; then for pair in $param_opt; do case $pair in service_id=*) __SERVICE_ID=${pair#*=}; write_log 7 "service_id: $__SERVICE_ID" ;; zone_id=*) __ZONE_ID=${pair#*=}; write_log 7 "zone_id: $__ZONE_ID" ;; record_id=*) __RECORD_ID=${pair#*=}; write_log 7 "record_id: $__RECORD_ID" ;; + ttl=*) __TTL=${pair#*=}; write_log 7 "ttl: $__TTL" ;; *) ;; esac done @@ -111,6 +144,23 @@ fi [ -z "$__ZONE_ID" ] && write_log 14 "param_opt missing zone_id=VALUE" [ -z "$__RECORD_ID" ] && write_log 14 "param_opt missing record_id=VALUE" +# Validate __TTL is a positive integer before applying the default. +# json_add_int passes the value directly to jshn without type-checking; +# a non-numeric value would silently produce malformed JSON and cause +# the API to reject the update with an opaque error. +if [ -n "$__TTL" ]; then + case "$__TTL" in + *[!0-9]*) + write_log 14 "param_opt ttl=VALUE must be a positive integer (got: '$__TTL')" + ;; + 0) + write_log 14 "param_opt ttl=VALUE must be greater than 0 (got: '$__TTL')" + ;; + esac +fi +# Default TTL to 300 seconds if not provided via param_opt. +__TTL="${__TTL:-300}" + # set record type based on use_ipv6 flag local __TYPE __IPVERSION if [ "${use_ipv6:-0}" -eq 0 ]; then @@ -122,29 +172,109 @@ else fi local __URLBASE="https://my.blazingfast.io/api" -local __TOKEN __DATA __RECTYPE __DEVICE __PAYLOAD +# __TOKEN, __DATA, __RECTYPE: shared across steps. +# __DEVICE: set later from bind_network if configured. +# __PAYLOAD is intentionally NOT declared here — it is a short-lived +# intermediate used only in Step 4 and is declared there to keep its +# scope and intent clear. +local __TOKEN __DATA __RECTYPE __DEVICE local __CURLCFG="${DATFILE}.curl" local __CURLEXTRA="${DATFILE}.extra" +local __JSONFILE="${DATFILE}.json" +# Token cache — lives in /var/run/ddns/ and persists across invocations. +# Named per service_id so dual-stack or multi-zone setups don't collide. +# Format: " " +# Not removed on clean exit; the TTL check handles expiry naturally. +local __TOKENFILE="/var/run/ddns/blazingfast_${__SERVICE_ID}.token" +local __TOKEN_TTL=270 # seconds — 4.5 min; safely under any reasonable JWT expiry +# --------------------------------------------------------------------------- # Explicit cleanup helper. We deliberately avoid `trap ... EXIT` because this # script is sourced into the long-running ddns runtime, where a global trap # would leak past this provider invocation and could clobber unrelated files # or override traps installed by the framework / other providers. +# --------------------------------------------------------------------------- blazingfast_cleanup() { - rm -f "$__CURLCFG" "$__CURLEXTRA" + rm -f "$__CURLCFG" "$__CURLEXTRA" "$__JSONFILE" } -# ------------------------------------------------------------------- -# blazingfast_transfer — invokes curl via config file +# --------------------------------------------------------------------------- +# blazingfast_do_login — performs the login API call, extracts the token, +# and writes it to the cache file atomically with restricted permissions. +# +# Atomic write (draft to __TOKENFILE.tmp.$$ then mv) prevents concurrent +# dual-stack instances from reading a partially-written file. The $$ PID +# suffix gives each concurrent instance its own temp filename so they never +# clobber each other's draft. The subshell confines umask 077 so the temp +# file is owner-readable only, without affecting the parent shell's umask. +# +# Sets __TOKEN on success. Clears the cache file and returns 1 on failure. +# --------------------------------------------------------------------------- +blazingfast_do_login() { + : > "$__CURLEXTRA" + echo "request = POST" >> "$__CURLEXTRA" + echo "url = \"$__URLBASE/login\"" >> "$__CURLEXTRA" + # Use data-urlencode so credentials containing reserved characters + # (&, =, +, spaces, @, ...) are safely percent-encoded by curl. + printf 'data-urlencode = "username=%s"\n' "$username" >> "$__CURLEXTRA" + printf 'data-urlencode = "password=%s"\n' "$password" >> "$__CURLEXTRA" + blazingfast_transfer || { + write_log 4 "Blazingfast authentication request failed" + return 1 + } + + __TOKEN=$(jsonfilter -i "$DATFILE" -e "@.token" 2>/dev/null) + if [ -z "$__TOKEN" ]; then + # Do NOT dump $DATFILE: a partial/successful response may contain a token. + write_log 4 "Blazingfast authentication failed — check username/password" + rm -f "$__TOKENFILE" + return 1 + fi + + # Write atomically: draft to a per-PID temp file, then rename into place. + # The rename is atomic on POSIX filesystems, so a concurrent reader either + # sees the old complete file or the new complete file — never a partial write. + # The subshell confines umask 077 so the temp file is owner-readable only. + local __TMPTOK="${__TOKENFILE}.tmp.$$" + ( umask 077 && printf '%s %s\n' "$(date +%s)" "$__TOKEN" > "$__TMPTOK" ) && \ + mv "$__TMPTOK" "$__TOKENFILE" || { + rm -f "$__TMPTOK" + write_log 4 "Failed to write Blazingfast token cache" + return 1 + } + return 0 +} + +# --------------------------------------------------------------------------- +# blazingfast_fetch_zone — fetches all DNS records for the configured zone. +# +# Extracted into a helper to avoid duplicating the GET request block: it is +# called once during normal operation (Step 2) and again if a cached token +# is found to have expired mid-session and a fresh login has been performed. +# Result is written to $DATFILE for the caller to parse. +# --------------------------------------------------------------------------- +blazingfast_fetch_zone() { + : > "$__CURLEXTRA" + echo "request = GET" >> "$__CURLEXTRA" + echo "url = \"$__URLBASE/service/$__SERVICE_ID/dns/$__ZONE_ID\"" >> "$__CURLEXTRA" + echo "header = \"Authorization: Bearer $__TOKEN\"" >> "$__CURLEXTRA" + echo "header = \"Content-Type: application/json\"" >> "$__CURLEXTRA" + blazingfast_transfer +} + +# --------------------------------------------------------------------------- +# blazingfast_transfer — invokes curl via config file. # Avoids eval and shell injection from user-controlled values. # Call-specific options (method, url, headers, data) are written # to __CURLEXTRA before each call and appended to the base config. -# ------------------------------------------------------------------- +# --------------------------------------------------------------------------- blazingfast_transfer() { local __CNT=0 local __ERR while : ; do + : > "$DATFILE" + : > "$ERRFILE" # Build fresh curl config for this attempt : > "$__CURLCFG" echo "silent" >> "$__CURLCFG" @@ -187,14 +317,14 @@ blazingfast_transfer() { "$__CURLBIN" --config "$__CURLCFG" __ERR=$? - [ "$__ERR" -eq 0 ] && break + [ "$__ERR" -eq 0 ] && return 0 write_log 3 "cURL Error: '$__ERR'" write_log 7 "$(cat "$ERRFILE")" [ "${VERBOSE_MODE:-0}" -gt 1 ] && { write_log 4 "Transfer failed - Verbose Mode: ${VERBOSE_MODE:-0} - NO retry on error" - break + return "$__ERR" } __CNT=$(( __CNT + 1 )) @@ -216,50 +346,100 @@ if [ -n "$bind_network" ]; then write_log 7 "Force communication via device '$__DEVICE'" fi -# ------------------------------------------------------------------- +# --------------------------------------------------------------------------- # Step 1 — Authenticate and obtain JWT token -# ------------------------------------------------------------------- -write_log 7 "Authenticating with Blazingfast.io" +# +# To prevent API rate-limiting, tokens are cached to disk and reused for up +# to __TOKEN_TTL seconds. The ddns framework retries the full send_update() +# cycle (including this script) on each failure, so without caching every +# retry would hit the login endpoint — exactly the pattern that triggers +# Blazingfast's abuse protection and locks the account out of the API. +# +# Cache hit: reuse token, skip login request entirely. +# Cache miss: authenticate via blazingfast_do_login, which writes the token +# atomically with restricted permissions. +# Expiry: on a 401 from any subsequent API call the cache is invalidated +# and blazingfast_do_login is called once more. +# --------------------------------------------------------------------------- +__TOKEN="" +if [ -f "$__TOKENFILE" ]; then + local __CACHED_TS __CACHED_TOK __NOW __AGE + # IFS=' ' and -r ensure the read is not affected by the current IFS value + # or by backslash sequences that may appear in a corrupted cache file. + IFS=' ' read -r __CACHED_TS __CACHED_TOK 2>/dev/null < "$__TOKENFILE" + __NOW=$(date +%s) -: > "$__CURLEXTRA" -echo "request = POST" >> "$__CURLEXTRA" -echo "url = \"$__URLBASE/login\"" >> "$__CURLEXTRA" -# Use data-urlencode so credentials containing reserved characters -# (&, =, +, spaces, ...) are safely percent-encoded by curl. -printf 'data-urlencode = "username=%s"\n' "$username" >> "$__CURLEXTRA" -printf 'data-urlencode = "password=%s"\n' "$password" >> "$__CURLEXTRA" -blazingfast_transfer + case "$__CACHED_TS" in + ''|*[!0-9]*) + write_log 7 "Cached token timestamp is invalid — re-authenticating" + rm -f "$__TOKENFILE" + __CACHED_TS="" + ;; + esac + + if [ -n "$__CACHED_TS" ] && [ "$__NOW" -ge "$__CACHED_TS" ]; then + __AGE=$(( __NOW - __CACHED_TS )) + else + __AGE="$__TOKEN_TTL" + fi + + if [ -n "$__CACHED_TOK" ] && [ "$__AGE" -lt "$__TOKEN_TTL" ]; then + __TOKEN="$__CACHED_TOK" + write_log 7 "Reusing cached Blazingfast token (age: ${__AGE}s / TTL: ${__TOKEN_TTL}s)" + else + write_log 7 "Cached token expired (age: ${__AGE}s) — re-authenticating" + rm -f "$__TOKENFILE" + fi +fi -__TOKEN=$(jsonfilter -i "$DATFILE" -e "@.token" 2>/dev/null) if [ -z "$__TOKEN" ]; then - # Do NOT dump $DATFILE: a partial/successful response may contain a token. - write_log 4 "Blazingfast authentication failed — check username/password" + write_log 7 "Authenticating with Blazingfast.io" + blazingfast_do_login || { blazingfast_cleanup; return 1; } + write_log 7 "Authentication successful — token cached" +fi + +# --------------------------------------------------------------------------- +# Step 2 — Fetch all zone records and verify record type +# +# The Blazingfast API does not support single-record GET requests. All records +# for the zone are fetched and filtered by record_id. This ensures IPv4 +# updates only target A records and IPv6 updates only target AAAA records. +# +# If the fetch returns a 401, the cached token expired between being written +# and used (e.g. the token's server-side TTL is shorter than __TOKEN_TTL). +# In that case the cache is wiped, a fresh login is performed via +# blazingfast_do_login, and the zone fetch is retried exactly once — this +# handles the race without hammering the login endpoint on other failures. +# --------------------------------------------------------------------------- +write_log 7 "Fetching zone records to verify record type is '$__TYPE'" +blazingfast_fetch_zone || { blazingfast_cleanup return 1 -fi -write_log 7 "Authentication successful" - -# ------------------------------------------------------------------- -# Step 2 — Fetch all zone records and verify record type -# The Blazingfast API does not support single-record GET requests. -# All records for the zone are fetched and filtered by record_id. -# Ensures IPv4 updates only target A records and IPv6 updates only -# target AAAA records. -# ------------------------------------------------------------------- -write_log 7 "Fetching zone records to verify record type is '$__TYPE'" - -: > "$__CURLEXTRA" -echo "request = GET" >> "$__CURLEXTRA" -echo "url = \"$__URLBASE/service/$__SERVICE_ID/dns/$__ZONE_ID\"" >> "$__CURLEXTRA" -echo "header = \"Authorization: Bearer $__TOKEN\"" >> "$__CURLEXTRA" -echo "header = \"Content-Type: application/json\"" >> "$__CURLEXTRA" -blazingfast_transfer +} # record id may be returned as integer or string depending on endpoint __RECTYPE=$(jsonfilter -i "$DATFILE" -e "@.records[@.id=$__RECORD_ID].type" 2>/dev/null) [ -z "$__RECTYPE" ] && \ __RECTYPE=$(jsonfilter -i "$DATFILE" -e "@.records[@.id='$__RECORD_ID'].type" 2>/dev/null) +if [ -z "$__RECTYPE" ]; then + local __APIERR + __APIERR=$(jsonfilter -i "$DATFILE" -e "@.error[0]" 2>/dev/null) + if [ "$__APIERR" = "unauthorized" ] && [ -f "$__TOKENFILE" ]; then + write_log 4 "Cached token rejected (expired) — clearing cache and re-authenticating" + rm -f "$__TOKENFILE" + blazingfast_do_login || { blazingfast_cleanup; return 1; } + write_log 7 "Re-authentication successful — fetching zone records again" + blazingfast_fetch_zone || { + blazingfast_cleanup + return 1 + } + __RECTYPE=$(jsonfilter -i "$DATFILE" -e "@.records[@.id=$__RECORD_ID].type" 2>/dev/null) + [ -z "$__RECTYPE" ] && \ + __RECTYPE=$(jsonfilter -i "$DATFILE" -e "@.records[@.id='$__RECORD_ID'].type" 2>/dev/null) + fi +fi + if [ -z "$__RECTYPE" ]; then write_log 4 "Could not retrieve DNS record type from Blazingfast API" write_log 7 "$(cat "$DATFILE")" @@ -275,14 +455,14 @@ fi write_log 7 "DNS record type confirmed: '$__RECTYPE'" -# ------------------------------------------------------------------- +# --------------------------------------------------------------------------- # Step 3 — GET_REGISTERED_IP mode -# Returns the IP currently stored in the DNS record, -# used by ddns-scripts to compare before deciding to update. # -# The zone records were already fetched during type verification, -# so reuse the existing API response from $DATFILE. -# ------------------------------------------------------------------- +# Returns the IP currently stored in the DNS record, used by ddns-scripts to +# compare against the local IP before deciding whether an update is needed. +# The zone records were already fetched in Step 2, so $DATFILE is reused +# here without an additional API call. +# --------------------------------------------------------------------------- if [ -n "$GET_REGISTERED_IP" ]; then __DATA=$(jsonfilter -i "$DATFILE" -e "@.records[@.id=$__RECORD_ID].content" 2>/dev/null) [ -z "$__DATA" ] && \ @@ -300,32 +480,49 @@ if [ -n "$GET_REGISTERED_IP" ]; then fi fi -# ------------------------------------------------------------------- +# --------------------------------------------------------------------------- # Step 4 — Update the DNS record -# JSON payload is built with jshn.sh so values are properly escaped -# and written inline to the curl config. This avoids any quoting or -# escaping issues when domain or IP contain special characters. -# ------------------------------------------------------------------- +# +# JSON payload is built with jshn.sh and written to a temp file, then +# referenced via curl's @file syntax. This avoids all quoting issues: +# embedding JSON inline in the curl config file breaks because the internal +# double-quotes in the JSON prematurely close the config file's quoted string. +# +# __PAYLOAD is declared here rather than in the shared locals block at the +# top because it is only used in this step. Keeping it local to its site of +# use makes the data flow easier to follow. +# +# TTL is taken from __TTL, which was parsed from param_opt and defaults to +# 300 seconds. This allows users to configure the TTL without editing the +# script by adding ttl=SECONDS to their param_opt value. +# --------------------------------------------------------------------------- +local __PAYLOAD json_init json_add_string "name" "$domain" -json_add_int "ttl" 300 +json_add_int "ttl" "$__TTL" json_add_int "priority" 0 json_add_string "type" "$__TYPE" json_add_string "content" "$__IP" __PAYLOAD=$(json_dump) +printf '%s' "$__PAYLOAD" > "$__JSONFILE" + : > "$__CURLEXTRA" echo "request = PUT" >> "$__CURLEXTRA" echo "url = \"$__URLBASE/service/$__SERVICE_ID/dns/$__ZONE_ID/records/$__RECORD_ID\"" >> "$__CURLEXTRA" echo "header = \"Authorization: Bearer $__TOKEN\"" >> "$__CURLEXTRA" echo "header = \"Content-Type: application/json\"" >> "$__CURLEXTRA" -printf 'data = "%s"\n' "$__PAYLOAD" >> "$__CURLEXTRA" -blazingfast_transfer +echo "data = \"@$__JSONFILE\"" >> "$__CURLEXTRA" +blazingfast_transfer || { + write_log 4 "Blazingfast update request failed" + blazingfast_cleanup + return 1 +} # verify success from API response __DATA=$(jsonfilter -i "$DATFILE" -e "@.info[0]" 2>/dev/null) if echo "$__DATA" | grep -q "dnsrecordupdated"; then - write_log 7 "Record updated: $domain $__TYPE -> $__IP" + write_log 7 "Record updated: $domain $__TYPE -> $__IP (TTL: ${__TTL}s)" blazingfast_cleanup return 0 fi