ddns-scripts: add blazingfast.io Anycast DNS provider

Add DDNS update support for blazingfast.io Anycast DNS via their
REST API. Authentication is performed via JWT token obtained from
the login endpoint. Zone records are fetched to verify the record
type before update, ensuring IPv4 services only target A records
and IPv6 services only target AAAA records.

Service, zone and record IDs are passed via param_opt as
space-separated key=value pairs:
  service_id=X zone_id=Y record_id=Z

curl --config file approach is used throughout to avoid eval and
shell injection from user-controlled values. Supports both IPv4
and IPv6. For dual-stack, create two separate DDNS service sections
with their respective record IDs.

Tested on GL.iNet MT5000 (Brume 3) running OpenWrt with
ddns-scripts 2.8.2.

Signed-off-by: Fotios Kitsantas <fkitsantas@icloud.com>
This commit is contained in:
Fotios Kitsantas
2026-05-19 19:39:50 +01:00
committed by Florian Eckert
parent 510d66fbc4
commit 288f220aa3
2 changed files with 264 additions and 67 deletions
+1 -1
View File
@@ -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
@@ -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: "<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"
@@ -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