dnsync
A Rust CLI + MCP server for managing DNS servers.
Use it interactively from the terminal, or run it as an MCP server so Claude can manage your DNS.
Supported vendors: Technitium · Pangolin · Cloudflare
Build
# Binary: ./target/release/dns
Configuration
Auth can be read from a config file, flags, or environment variables. Release
builds read the default config file from $XDG_CONFIG_HOME/dnsync/config.toml,
falling back to ~/.config/dnsync/config.toml on Linux. Debug builds use
./.config/dnsync/config.toml under the repository root so development changes
do not affect your real user config. If the selected config file does not
exist, dnsync creates it with safe defaults and no embedded secrets.
The config file must be readable only by its owner (chmod 600); the
containing directory must be owner-only as well (chmod 700). dnsync
sets these permissions automatically when it creates the file, and
refuses to start if it finds a config that is group- or world-readable.
Use --config /path/to/config.toml or DNSYNC_CONFIG=/path/to/config.toml to
load a custom config file. When a config contains multiple DNS servers, select
one with --server <id> or DNSYNC_SERVER=<id>.
--token and --base-url are explicit CLI overrides. Without them, credentials
are resolved from vendor-specific environment variables and the selected server's
config entry (see the resolution chain below).
To preview the starter config without writing any files:
To create the config file without starting the DNS client or requiring an API token:
To add a server entry interactively:
Or non-interactively with flags:
Example config
[[]]
= "dns1"
= "technitium"
= "local"
= "home-dns"
= "https://dns1-ui.hankin.io" # or use base_url_env to read from an env var
= "DNSYNC_DNS1_API_TOKEN"
[]
= true
= "10.5.0.53:53"
[]
= true
= "10.5.0.53:853"
= "dns1.hankin.io"
[]
= true
= "https://dns1.hankin.io/dns-query"
[]
= ["read"]
= ["example.com", "internal.lan"]
[[]]
= "dns2"
= "technitium"
= "local"
= "home-dns"
= "https://dns2-ui.hankin.io"
= "DNSYNC_DNS2_API_TOKEN"
[]
= true
= "10.5.161.84:53"
[]
= true
= "10.5.161.84:853"
= "dns2.hankin.io"
[]
= true
= "https://dns2.hankin.io/dns-query"
[]
= "technitium"
= ["dns1", "dns2"]
= "primary_only"
= "auto"
= "auto"
= "dns1"
[[]]
= "lab"
= "technitium"
= "http://192.168.1.20:5380"
= "DNSYNC_LAB_API_TOKEN"
[]
= ["delete"]
= ["lab.example.com"]
[[]]
= "cf"
= "cloudflare"
= "CLOUDFLARE_API_TOKEN"
[[]]
= "pg"
= "pangolin"
= "my-org"
= "PANGOLIN_API_TOKEN"
# Named record-sync profile — see the Sync section below.
[[]]
= "home" # invoked as `dns sync home`
= "cf" # source server id
= "home" # destination server id
= ["example.com"] # optional; omit to sync every zone on the source
[]
= "192.168.1.10"
= "192.168.1.11"
Each [[sync]] profile names a from/to pair of server ids and an optional
ip_map of external = internal address rewrites. Both sides of a mapping
must be valid IP addresses of the same family (IPv4↔IPv4 or IPv6↔IPv6).
Vendor defaults when no base_url is set:
technitium→http://localhost:5380pangolin→https://api.pangolin.net/v1cloudflare→https://api.cloudflare.com/client/v4
Per-server DNS transports are optional query endpoints used by validation today
and by future benchmarking and direct record-search features. Configure them with
[servers.dns], [servers.dot], and [servers.doh]. Plain DNS and DoT use
addr = "host:port"; DoT can also set server_name; DoH uses
url = "https://.../dns-query".
Logical clusters are optional and live under [clusters.<id>]. For Technitium,
use write_policy = "primary_only" with primary = "auto" so dnsync can use
live cluster discovery rather than trusting stale static primary/secondary roles.
preferred_writer is an operator hint, not a substitute for live role checks.
Legacy [[servers.validation_endpoints]] entries are still accepted for endpoint
validation. If no transports or validation endpoints are configured, validation
remains enabled but is skipped with reason no_validation_endpoints_configured.
MCP permissions are applied per selected server. access controls the maximum
permitted operation level (read = read-only, write = no deletes, delete =
full access), and allowed_zones restricts zone-targeting MCP tools to the
listed zones and their subdomains. --allow-zone / DNS_ALLOWED_ZONES can
further narrow configured zones for a launch, but cannot broaden a server's
configured allow-list.
Flags and environment variables override config values:
| Flag | Env var | Default |
|---|---|---|
--config |
DNSYNC_CONFIG |
release: $XDG_CONFIG_HOME/dnsync/config.toml; debug: ./.config/dnsync/config.toml |
--server |
DNSYNC_SERVER |
only server in config |
--base-url |
— | vendor env var → config base_url_env → base_url → vendor default |
--token |
— | vendor env var → config token_env → token |
--access |
DNS_ACCESS |
config access |
--allow-zone |
DNS_ALLOWED_ZONES |
config allowed_zones |
Credential resolution chain (highest priority first):
| Step | Base URL | Token |
|---|---|---|
| 1 | --base-url flag |
--token flag |
| 2 | vendor env var (DNSYNC_TECHNITIUM_BASE_URL, DNSYNC_PANGOLIN_BASE_URL, DNSYNC_CLOUDFLARE_BASE_URL) |
vendor env var (DNSYNC_TECHNITIUM_API_TOKEN, DNSYNC_PANGOLIN_API_TOKEN, DNSYNC_CLOUDFLARE_API_TOKEN) |
| 3 | config base_url_env → env lookup |
config token_env → env lookup |
| 4 | config base_url literal |
config token literal |
| 5 | vendor default URL | — |
Technitium also accepts legacy TECHNITIUM_BASE_URL / TECHNITIUM_API_TOKEN at step 2 (checked after DNSYNC_TECHNITIUM_*).
Pangolin additionally requires org_id — resolved from DNSYNC_PANGOLIN_ORG_ID then config org_id.
CLI Usage
dns [OPTIONS] <COMMAND>
Commands:
config Manage the config file (init, print, add)
mcp Start as MCP stdio server
zone Manage DNS zones
record Manage DNS records
sync Sync records between two configured servers, remapping IPs
cache Manage the DNS cache
stats View server statistics
blocked Manage blocked domains
allowed Manage allowed (whitelist) domains
settings Show server settings
completions Print a shell completion script to stdout
Config
Zones
Records
record list accepts an optional domain argument. Without a domain it lists all
records across all configured servers. Supply --zone to qualify a bare label
(e.g. huly + --zone hankin.io → huly.hankin.io); without --zone, the
domain is matched against all zones.
record add and record delete take the record type as a subcommand with
type-specific positional arguments:
# Add records
# Delete specific record
# Delete all records of a type for a domain
Supported record types for add and delete: a, aaaa, aname, app,
caa, cname, dname, ds, fwd, https, mx, naptr, ns, ptr,
sshfp, srv, svcb, tlsa, txt, uri, unknown.
For Pangolin servers, record list reads records from Pangolin's DNS API and
normalizes them into the same shape used by other vendors. --use-local-ip
optionally resolves A/AAAA record names with Hickory and prefers a
private/local address when one is visible; without the flag, provider API
values are preserved exactly.
Sync
dns sync copies records from one configured server to another, optionally
rewriting IP addresses on A/AAAA records — for example, mapping a public
address to its internal LAN equivalent ("split-horizon" DNS).
Sync is dry-run by default — it prints the planned changes and writes
nothing until --apply is passed. It is additive: it adds records the
destination is missing and updates record sets whose values differ, but never
prunes whole names that exist only on the destination. Server-managed records
(SOA, DNSSEC) and disabled records are skipped; source TTLs are preserved.
Because it reads and writes individual records through the vendor-neutral API,
sync works between any pair of supported vendors — including Pangolin, which
cannot participate in zone transfer.
Sync pairs and their IP-mapping tables can be stored as named [[sync]]
profiles in the config file (see below). CLI flags override the profile, and
--map SRC=DST entries merge into and override the profile's ip_map.
Cache
Stats
Blocked / Allowed
Shell completions
MCP Server
Claude Desktop
Use a config file with a named server entry — credentials are resolved via token_env and base_url in the config:
Where the config entry for home sets token_env = "MY_DNS_TOKEN". For a one-off setup without a config file, pass credentials as flags: