A lightweight, robust, event-driven Dynamic DNS (DDNS) daemon written in Rust.
rdns replaces rigid, vendor-specific SDK abstractions with a unified, high-performance HTTP template engine. It natively supports atomic dual-stack (IPv4 + IPv6) updates in a single request, intelligent network event watching, and RFC 4291 EUI-64 SLAAC identification.
-
⚡ Event-Driven Change Detection (
netwatcher):- Listens directly to OS-level network interface changes.
- Eliminates wasteful empty polling while keeping a reliable fallback timer.
- Built-in debounce coalescing and virtual adapter filtering (
vEthernet, WSL, Docker, TAP/TUN) with whitelist support for user-configured adapters. - Modern Standby / Sleep-wake protection with DNS readiness probes.
-
🌐 Decoupled Interface Pools & Unified Dual-Stack:
- Network egress profiles (
interfaces) support unified dual-stackip:resolution (concurrently detecting IPv4 and IPv6) with auto-adapting remote parsers and higher-priorityipv4:/ipv6:overrides. - An interface performs periodic IP detection once, and all bound tasks reuse the resolved result ("probe once, fan out to many").
- Network egress profiles (
-
🔄 Native Dual-Stack & Single-Request Atomic Updates:
- Concurrently queries and validates both IPv4 and IPv6 addresses.
- Injects both IPs into a single HTTP request (e.g., Cloudflare batch API or custom webhooks).
-
🧩 Zero Hardcoded Provider SDKs & Adaptive Template Engine:
- Fully driven by declarative HTTP request templates (URL, Headers, Body, Method).
- Supports variable substitution (
{{ipv4}},{{ipv6}},{{domain}},{{args.param}}) alongside Conditional Replacement Patterns ({{?var:THEN|ELSE}}) and quote-aware JSON comma cleaning. - Declarative response assertions via HTTP status codes and regex patterns (e.g.
good|nochg).
-
📦 Built-in Provider Presets:
- Includes ready-to-use templates for
cloudflare,dynu,dynv6,duckdns, Hurricane Electric (he), andnoipacross single-stack and atomic dual-stack configurations, plus seamless raw HTTP webhook extensibility.
- Includes ready-to-use templates for
-
🔍 RFC 4291 EUI-64 SLAAC Recognition:
- Automatically identifies and prioritizes stable hardware-derived SLAAC IPv6 addresses over temporary privacy (RFC 4941) or transient DHCPv6 addresses.
-
🛡️ DNS Drift Detection & Active Reconciliation:
- Queries authoritative/public DNS servers via asynchronous
hickory-resolver. - Actively detects discrepancies between local IP and registered cloud DNS records, initiating self-healing updates.
- Queries authoritative/public DNS servers via asynchronous
-
⏱️ Adaptive Dual-Speed Scheduling:
- Operates at normal interval (default
300s) during steady state. - Automatically enters shortened
retry_interval(default60s) when update fails and DNS records mismatch.
- Operates at normal interval (default
-
💾 Atomic Local State Persistence:
- Non-blocking state cache using atomic write-and-rename (
state.json). - Prevents redundant write requests across daemon restarts.
- Non-blocking state cache using atomic write-and-rename (
-
📢 Webhook Notifications:
- Supports customizable HTTP alerts on IP changes (
on_change) and task failures (on_failure).
- Supports customizable HTTP alerts on IP changes (
-
🔒 Native & Custom Root Certificates:
- Automatically loads host platform native root CA certificates into
rustls(viarustls-native-certs). - Supports loading extra custom CA certificate bundles or private/corporate roots (
cacerts) globally or per-task.
- Automatically loads host platform native root CA certificates into
-
🪶 Embedded & Server Friendly:
- Configurable Tokio worker threads (
worker_threads: 1for memory-constrained embedded routers, multi-threaded for servers). - Clean, standardized OpenWrt DDNS style operational logs.
- Configurable Tokio worker threads (
Ensure you have Rust (edition 2024 / Rust 1.85+) installed:
git clone https://github.com/spritetong/rdns.git
cd rdns
cargo build --releaseThe optimized binary will be placed in target/release/rdns (or rdns.exe on Windows).
# Verify configuration syntax and parameter validity
rdns --check -c config.yaml
# Perform a dry-run preview (generates templates without sending HTTP writes)
rdns --dry-run -c config.yaml
# Run a single update cycle and exit
rdns --once -c config.yaml
# Run as a continuous daemon
rdns -c config.yaml
# Disable netwatcher event listening (pure polling mode)
rdns --no-netwatcher -c config.yamlCopy config-example.yaml to config.yaml and adjust it to your environment:
cp config-example.yaml config.yamlglobal:
interval: 300 # Default polling interval in seconds
retry_interval: 60 # Fast recovery retry interval in seconds
timeout: 10 # HTTP request timeout
shutdown_timeout: 10 # Maximum graceful shutdown drain timeout
worker_threads: 2 # Tokio worker threads (1 for embedded routers)
log_level: "info" # trace | debug | info | warn | error | off
# cacerts: "/path/to/extra-ca-certificates.pem" # Optional custom CA bundle (PEM or DER)
netwatcher: true # Enable event-driven network change detection
netwatcher_debounce_ms: 2000 # Debounce window for network events (ms)
# Generic notification webhooks (optional)
notification:
on_change:
enabled: true
method: "POST"
url: "https://api.example.com/notify"
headers:
Content-Type: "application/json"
body: |
{
"event": "ip_changed",
"task": "{{task_name}}",
"old_ip": "{{old_ip}}",
"new_ip": "{{new_ip}}"
}
# Network egress interfaces
interfaces:
- name: "Local"
interval: 300
dns_server: "8.8.8.8"
# Unified dual-stack IP resolution (simultaneously detects IPv4 and IPv6)
ip:
source: "remote" # "remote" or "interface"
urls:
- "https://api64.ipify.org"
- "https://icanhazip.com"
- "https://ident.me"
# Optional overrides: ipv4 / ipv6 have higher priority and override ip settings
# ipv6:
# source: "interface"
# interface: "eth0" # Or Windows friendly name like "以太网"
# prefer_slaac: true # Prioritize RFC 4291 EUI-64 SLAAC address
# DDNS Tasks
tasks:
- name: "cloudflare-ddns"
interface: "Local"
domain: "example.com"
provider: "cloudflare" # Built-in dual-stack Cloudflare preset
args:
token: "${CF_API_TOKEN}" # Supports environment variable expansion
zone_id: "<your_zone_id>"
record_id_v4: "<record_id_v4>"
record_id_v6: "<record_id_v6>"rdns tasks can be configured using either built-in provider presets or custom raw HTTP webhooks.
When rdns executes an update, placeholders in URLs, headers, and request bodies are substituted with current runtime values:
| Variable | Description | Example Output |
|---|---|---|
{{ipv4}} |
Resolved public IPv4 address | 203.0.113.195 |
{{ipv6}} |
Resolved public IPv6 address | 2001:db8::8a2e:370:7334 |
{{domain}} |
Configured task domain | sub.example.com |
{{task_name}} |
Name of the task | cloudflare-ddns |
{{timestamp}} |
Current Unix epoch timestamp (seconds) | 1741780000 |
{{args.KEY}} or {{KEY}}
|
Custom parameters defined under args
|
{{token}}, {{zone_id}}
|
${ENV_VAR} |
Environment variable (interpolated on config load) | ${CF_API_TOKEN} |
{{?var:THEN}} |
Conditional Pattern: renders THEN (with {} replaced by var) if var is active, else omitted |
{{?ipv4:&ip={}}} &ip=1.2.3.4 or ""
|
{{?var:THEN|ELSE}} |
Conditional with Else: renders THEN if var is active, else renders ELSE
|
{{?ipv4:&myip={}|&myip=no}} &myip=1.2.3.4 or &myip=no
|
Tip
Interface Configuration & Protocol Overrides:
- Unified
ip:option: Automatically detects both IPv4 and IPv6 using the same source (remoteorinterface). Remote probes auto-adapt to plain text, JSON ({"ip": ...}), or quoted responses. - Higher-Priority
ipv4:/ipv6:overrides: Define dedicatedipv4:oripv6:to overrideip:settings (or setenabled: falseto disable a protocol entirely for this interface). - Task-level protocol suppression via
args:- Set
ipv4: null(oripv4: ~) in the task'sargs:to suppress IPv4 and make it an IPv6-only task. - Set
ipv6: null(oripv6: ~) inargs:to suppress IPv6 and make it an IPv4-only task. - Specify a static IP string (e.g.
ipv4: "1.2.3.4") inargs:to override interface resolution for that task.
- Set
- If an interface only defines
ipv4:oripv6:, tasks bound to it automatically run in IPv4-only or IPv6-only mode, and conditional patterns ({{?ipv4:...}}/{{?ipv6:...}}) adapt accordingly without any manual configuration.
If your DNS provider is not among the built-in presets, or if you want to notify a custom API, internal microservice, or router webhook, omit provider and define a custom request block:
tasks:
- name: "custom-dualstack-webhook"
interface: "Local"
domain: "home.example.com"
request:
method: "POST"
url: "https://api.custom-dns.com/v1/update"
headers:
Authorization: "Bearer ${DNS_API_KEY}"
Content-Type: "application/json"
body: |
{
"domain": "{{domain}}",
"ipv4": "{{ipv4}}",
"ipv6": "{{ipv6}}",
"updated_at": {{timestamp}}
}
# Assertions: regex match or required substrings in response
success_regex: '"status"\s*:\s*"success"'
success_contains:
- "success"
# Optional network and TLS settings
proxy: "http://127.0.0.1:7890" # Optional HTTP or SOCKS5 proxy
tls_insecure: false # Set true to bypass TLS certificate verification
cacerts: "/etc/ssl/certs/internal-ca.pem" # Task-specific custom CA certificatestasks:
- name: "custom-adaptive-ddns"
interface: "Local"
domain: "myserver.dynamic-dns.org"
request:
method: "GET"
# Automatically includes &ip= or &ipv6= only when that protocol is active on the interface/task
url: "https://ddns.example.org/nic/update?hostname={{domain}}&key=${DDNS_KEY}{{?ipv4:&ip={}}}{{?ipv6:&ipv6={}}}"
success_contains:
- "good"
- "nochg"rdns ships with predefined templates for common DDNS configurations. All major providers (cloudflare, dynu, dynv6, duckdns, and he) are adaptive: they automatically support dual-stack, IPv4-only, or IPv6-only environments through conditional template replacement and JSON comma cleaning.
Tip
Cloudflare Helper Script: If you don't know your zone_id or record_id, use the helper script scripts/cf_lookup.py (uses Python standard library only) to automatically discover your Zone ID and DNS Record IDs, and generate ready-to-use YAML task configurations:
python scripts/cf_lookup.py -d sub.example.com(Interactive prompt for your API token if omitted or not exported in CF_API_TOKEN)
The cloudflare provider uses the atomic batch update API (/dns_records/batch). Thanks to conditional template rendering and JSON normalizer, a single cloudflare provider configuration automatically handles:
- Dual-Stack: Updates both A and AAAA records in a single atomic request.
- IPv4-Only: Updates only the A record (e.g. when the interface only has IPv4 or
ipv6: nullis set). - IPv6-Only: Updates only the AAAA record (e.g. when the interface only has IPv6 or
ipv4: nullis set).
tasks:
# Dual-stack atomic batch update
- name: "cf-dualstack"
interface: "Local"
domain: "sub.example.com"
provider: "cloudflare" # Aliases: "cf", "cf-dualstack", "cloudflare-dualstack"
args:
token: "${CF_API_TOKEN}"
zone_id: "${CF_ZONE_ID}"
record_id_v4: "${CF_RECORD_ID_V4}"
record_id_v6: "${CF_RECORD_ID_V6}"
# IPv4-only update on a dual-stack interface
- name: "cf-v4-only"
interface: "Local"
domain: "ipv4.example.com"
provider: "cloudflare"
args:
token: "${CF_API_TOKEN}"
zone_id: "${CF_ZONE_ID}"
record_id_v4: "${CF_RECORD_ID_V4}"
ipv6: null # Suppresses IPv6 for this task
# IPv6-only update on a dual-stack interface
- name: "cf-v6-only"
interface: "Local"
domain: "ipv6.example.com"
provider: "cloudflare"
args:
token: "${CF_API_TOKEN}"
zone_id: "${CF_ZONE_ID}"
record_id_v6: "${CF_RECORD_ID_V6}"
ipv4: null # Suppresses IPv4 for this taskAutomatically adapts to dual-stack, IPv4-only, or IPv6-only:
tasks:
- name: "dynu-adaptive"
interface: "Local"
domain: "yourname.freeddns.org" # Optional if username or group is specified
provider: "dynu"
args:
password: "${DYNU_PASSWORD}"
# username: "optional_username" # Account username (mandatory when group is used)
# group: "optional_group" # Group name to update a collection of hostnames
# ipv4: null # Optional: suppress IPv4 for IPv6-only
# ipv6: null # Optional: suppress IPv6 for IPv4-onlyAutomatically adapts to dual-stack, IPv4-only, or IPv6-only:
tasks:
- name: "dynv6-adaptive"
interface: "Local"
domain: "yourname.dynv6.net"
provider: "dynv6"
args:
token: "${DYNV6_TOKEN}"
# ipv4: null # Optional: suppress IPv4 for IPv6-only
# ipv6: null # Optional: suppress IPv6 for IPv4-onlyAutomatically adapts to dual-stack, IPv4-only, or IPv6-only:
tasks:
- name: "duckdns-adaptive"
interface: "Local"
domain: "yoursubdomain" # Subdomain prefix without .duckdns.org
provider: "duckdns"
args:
token: "${DUCKDNS_TOKEN}"
# ipv4: null # Optional: suppress IPv4 for IPv6-only
# ipv6: null # Optional: suppress IPv6 for IPv4-onlyAutomatically adapts to dual-stack, IPv4-only, or IPv6-only:
tasks:
- name: "he-adaptive"
interface: "Local"
domain: "yourname.dyn.he.net"
provider: "he" # Alias: "hurricane-electric"
args:
password: "${HE_PASSWORD}"
# ipv4: null # Optional: suppress IPv4 for IPv6-only
# ipv6: null # Optional: suppress IPv6 for IPv4-onlytasks:
- name: "noip-v4"
interface: "Local"
domain: "yourname.ddns.net"
provider: "noip"
args:
username: "${NOIP_USERNAME}"
password: "${NOIP_PASSWORD}"You can combine a built-in provider with a request block to selectively customize proxy settings, TLS options, headers, or extra CA certificates without having to rewrite the URL or payload:
tasks:
- name: "cf-with-proxy"
interface: "Local"
domain: "sub.example.com"
provider: "cloudflare"
args:
token: "${CF_API_TOKEN}"
zone_id: "${CF_ZONE_ID}"
record_id_v4: "${CF_RECORD_ID_V4}"
record_id_v6: "${CF_RECORD_ID_V6}"
request:
proxy: "socks5://127.0.0.1:1080" # Route updates through a SOCKS5 proxy
cacerts: "/etc/ssl/certs/internal-ca.pem" # Extra custom CA certificates
# headers:
# X-Custom-Header: "CustomValue"Usage: rdns [OPTIONS]
Options:
-c, --config <CONFIG>
Path to YAML configuration file [default: config.yaml]
--once
Single execution mode: update once and immediately exit
--daemon
Long-running daemon mode with asynchronous interval polling
--dry-run
Dry-run mode: fetch IP and render templates without sending live HTTP write requests
--check
Validate configuration syntax and interface existence without running
--list-providers
List all predefined DDNS providers and their templates
--show-provider <NAME>
Query and show default request template and details for a predefined provider (for Cloudflare, see also 'python scripts/cf_lookup.py') [alias: --provider]
-t, --worker-threads <THREADS>
Number of Tokio runtime worker threads (1 for single-thread lightweight runtime)
-s, --state <PATH>
Override the path to the state persistence JSON file
--write-state [<BOOL>]
Control whether to write the state persistence file to disk (true/false) [alias: --save-state]
--no-state
Disable writing the state persistence file to disk [aliases: --no-state-file, --no-save-state]
--no-netwatcher
Disable netwatcher event-driven network change detection (fallback to timer polling)
-l, --log-level <LEVEL>
Log level filter (trace, debug, info, warn, error, off) [default: info]
-h, --help
Print help
-V, --version
Print version
Cloudflare Helper:
Use 'python scripts/cf_lookup.py -d <DOMAIN>' to query Zone ID and Record IDs from Cloudflare.
This project is licensed under the GNU General Public License v3.0 (GPL-3.0-or-later). See the LICENSE file for details.
Author: Sprite Tong (spritetong@gmail.com)
