Unified DDNS Client
ddnsd is a single-binary daemon that keeps DNS records and CDN/edge origins pointed at your current public IP. It periodically detects the machine's public IPv4/IPv6 address and, whenever it changes, pushes the new address to every configured target — a Cloudflare record, an Alibaba Cloud DNS record, a Tencent EdgeOne origin, and more — from one config file.
- One daemon, many targets — sync any number of DNS records and CDN origins across different providers at once.
- DNS and origin following — besides classic A/AAAA record updates, it can rewrite the origin address of CDN / edge-acceleration services (Tencent EdgeOne, Tencent CDN, Alibaba Cloud ESA), which plain DDNS clients cannot do.
- Dual stack — IPv4 and IPv6 are detected, tracked, and retried independently per target.
- Change detection — provider APIs are only called when the IP actually changed since the last successful apply; failed applies are retried on the next tick.
- Deterministic IP detection — the v4/v6 probes use address-family-pinned HTTP clients, so a dual-stack IP service always reports the right family.
- Hot reload — send
SIGHUPto reloadconfig.yamlwithout restarting; check period, probe DNS resolver, and provider list are updated in place, and last-applied state is carried over so nothing is re-applied spuriously. - Structured logging with rotation — zap logs to stdout plus optional rotating JSON files (a full log and a warn+ error log) via lumberjack.
type |
Service | Credentials |
|---|---|---|
cloudflare |
Cloudflare DNS | API token |
aliyun-dns |
Alibaba Cloud DNS (云解析 DNS) | AccessKey ID / Secret |
dnspod |
Tencent Cloud DNSPod (DNS 解析) | SecretId / SecretKey |
volcengine-dns |
Volcengine DNS (火山引擎云解析) | AccessKey / SecretKey |
type |
Service | Notes |
|---|---|---|
tencent-edgeone |
Tencent EdgeOne (边缘安全加速平台) | Two modes: origin group (dual stack) or acceleration domain (single family) |
tencent-cdn |
Tencent CDN (内容分发网络) | Updates an accelerated domain's origin |
aliyun-esa |
Alibaba Cloud ESA (边缘安全加速) | Updates an origin inside an origin pool |
Requires Go ≥ 1.26.
go build -ldflags="-s -w" -trimpath -o ddnsdddnsd reads config.yaml from its working directory. The config.yaml in this repository is a fully commented example — copy it next to the binary and edit it. A minimal config:
# Check interval in seconds.
period: 60
api:
# Public IP source per family: "ipip.net", "gh.ink", or a custom URL.
# Leave blank to disable that family.
v4: "ipip.net"
v6: ""
# resolver: "" # optional custom DNS server for probe-hostname lookups
providers:
- type: cloudflare
name: home
ipv4: true
ipv6: false
token: "your-api-token"
zone: "your-zone-id"
record: "home.example.com"./ddnsdThe daemon runs in the foreground, checks the public IP every period seconds, and applies changes to all configured providers. Reload the config at runtime with:
kill -HUP $(pidof ddnsd)| Key | Description | Default |
|---|---|---|
period |
Check interval in seconds | 60 when unset or invalid |
api.v4 / api.v6 |
Public IP source for that family: ipip.net, gh.ink, or a custom URL. Empty disables the family. |
disabled |
api.resolver |
Optional custom DNS server (an IP literal, optionally ip:port) used to resolve probe hostnames. Empty or invalid uses the system resolver. |
system resolver |
log.file.all |
Rotating JSON log with everything (empty disables) | disabled |
log.file.err |
Rotating JSON log with warnings and above (empty disables) | disabled |
log.max_size |
Max size of a log file in MB before rotation | — |
log.max_backups |
Max number of rotated files to keep | — |
log.max_age |
Max age of rotated files in days | — |
log.compress |
Compress rotated files | false |
A custom IP source must respond 200 OK with a bare IP as the body (e.g. 1.1.1.1) and include the protocol in the URL. The URL is fetched over the matching address family only, so the service must be reachable via that family.
Probe hostnames (myip.ipip.net, v4/v6-myip.gh.ink, or the host in a custom API URL) are resolved with the system resolver by default. Set api.resolver to a DNS server IP — optionally with a port, e.g. 1.1.1.1:5353 — to route those lookups through that server instead; useful when the probe hostnames are only reachable via a specific resolver.
Every entry under providers: shares four common fields; the rest are provider-specific:
| Key | Description |
|---|---|
type |
Provider type, one of the tables above |
name |
Optional friendly name used in logs |
ipv4 |
Track the A record / v4 origin |
ipv6 |
Track the AAAA record / v6 origin |
At least one of ipv4/ipv6 must be true, otherwise the entry is skipped.
| Key | Description |
|---|---|
token |
API token with DNS edit permission |
zone |
Zone ID |
record |
Full record name (FQDN), e.g. home.example.com |
| Key | Description |
|---|---|
access_key_id / access_key_secret |
Alibaba Cloud credentials |
domain |
Apex domain, e.g. example.com |
record |
FQDN; the RR/host label is derived from it |
endpoint |
Optional API endpoint, defaults to alidns.aliyuncs.com |
| Key | Description |
|---|---|
secret_id / secret_key |
Tencent Cloud credentials |
domain |
Apex domain |
record |
FQDN |
line |
Optional record line, defaults to 默认 |
| Key | Description |
|---|---|
access_key / secret_key |
Volcengine credentials |
domain |
Apex domain |
record |
FQDN |
region |
Optional region, e.g. cn-north-1 |
| Key | Description |
|---|---|
secret_id / secret_key |
Tencent Cloud credentials |
zone_id |
EdgeOne zone ID, e.g. zone-xxx |
origin_group_id |
Mode A — origin group: rewrites the matching-family record inside the group via read-modify-write; other-family and domain records survive, so dual stack works |
domain_name |
Mode B — acceleration domain: binds the accelerated domain directly to a single IP origin (no origin group needed); single family only |
region |
Optional API region |
Set exactly one of origin_group_id / domain_name.
| Key | Description |
|---|---|
secret_id / secret_key |
Tencent Cloud credentials |
domain |
Accelerated domain whose origin should follow the IP |
| Key | Description |
|---|---|
access_key_id / access_key_secret |
Alibaba Cloud credentials |
site_id |
ESA site (站点) ID |
origin_pool_id |
Origin pool (源站池) ID |
origin_name |
Optional: which origin in the pool to update |
endpoint |
Optional API endpoint, defaults to esa.cn-hangzhou.aliyuncs.com |
- Each provider entry becomes one independent job per enabled family, so v4 and v6 are change-detected and retried separately.
- A provider API is only called when the detected IP differs from the last successfully applied value; a failed apply keeps the old state so the next tick retries it.
- Every provider API call is bounded by a 30-second timeout.
- If an IP probe fails or a family is disabled, that family is simply skipped for the tick — other targets still sync.
- On
SIGHUPthe config is re-read: the check period is re-registered if changed and the provider set is rebuilt, carrying over last-applied state for entries that still exist. - If a
config_debug.yamlexists in the working directory it is loaded instead ofconfig.yamland the log level switches to debug.
[Unit]
Description=ddnsd - unified DDNS client
After=network-online.target
Wants=network-online.target
[Service]
WorkingDirectory=/opt/ddnsd
ExecStart=/opt/ddnsd/ddnsd
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
[Install]
WantedBy=multi-user.targetgo test ./...Project layout:
main.go entry point: config → logger → checker → cron
internal/config viper-based config loading, SIGHUP hot reload hooks
internal/checker public IP detection (family-pinned clients) + sync driver
internal/provider Provider interface, registry, change-detecting Manager
internal/provider/<type> one package per provider, self-registered via init()
internal/cron periodic scheduler (gocron)
internal/logger zap + lumberjack logging
Adding a provider: create internal/provider/<name>, implement the Provider interface (Name() + idempotent Apply(ctx, family, ip)), call provider.Register("<type>", factory) in init(), and blank-import the package in internal/provider/all/all.go.