#
# 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
# - 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
. /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
[ -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
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: "<unix_timestamp> <jwt_token>"
+# 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"
"$__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 ))
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)
+
+ 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
-: > "$__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
-
-__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"
- blazingfast_cleanup
- return 1
+ write_log 7 "Authenticating with Blazingfast.io"
+ blazingfast_do_login || { blazingfast_cleanup; return 1; }
+ write_log 7 "Authentication successful — token cached"
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.
-# -------------------------------------------------------------------
+#
+# 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'"
-
-: > "$__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_fetch_zone || {
+ blazingfast_cleanup
+ return 1
+}
# 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")"
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" ] && \
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