Magic DNS (peer hostnames)
Joined peers are reachable by hostname, the way Tailscale Magic DNS is: ping laptop and ssh pi.svpn resolve to that node's tunnel address.
Automatic assignment is the node-IP piece. This page is the node-name piece. Both require learning mode — --nat shares a placeholder IP, so names would not map to unique addresses.
client A "laptop" hub client B "pi"
10.9.0.5 10.9.0.1 10.9.0.7
│ NameAdvert │ NameAdvert │
├──────────────────────────────►│◄─────────────────────────┤
│ PeerPush │ PeerPush │
│ laptop→.5 pi→.7 vpn→.1 │ (same table) │
│◄──────────────────────────────┤──────────────────────────►
local stub answers │ local stub answers
"pi" / "pi.svpn" → 10.9.0.7 │ "laptop.svpn" → .5Setup
Magic DNS is on by default. Each client announces a hostname (the sanitized OS hostname unless you set one) on its keepalive tick. The server grants the name — first-come keeps laptop; a later collision becomes laptop-aabb — and pushes the whole map back. The client DNS stub answers A/AAAA from that table. No extra control plane, no /etc/hosts.
{
"server": "vpn.example.com:8388",
"password": "correct horse battery staple",
"hostname": "laptop"
}sudo shadowvpn-client -s vpn.example.com:8388 -k <password> --hostname laptop
# another device
sudo shadowvpn-client -s vpn.example.com:8388 -k <password> --hostname piOmit hostname and the client uses the OS hostname (My-Laptop.local → my-laptop). The server publishes its own name (default: its OS hostname) as its tunnel IP, so ping vpn / ssh <server-hostname>.svpn works too.
Names
| Form | Example | Notes |
|---|---|---|
| Bare label | pi | Works when the stub is the system resolver |
| Suffixed | pi.svpn | Always unique to the Magic DNS zone |
| Server | vpn.svpn | Server hostname → server tun_ip |
The suffix is svpn by default (magic_dns_suffix / --magic-dns-suffix). Unknown names under that suffix are NXDOMAIN — they never leak to an upstream resolver.
Sanitization: first label, lowercase, invalid characters become -, max 32 bytes. Empty → node.
Collisions: the first advertiser keeps the requested name. A later node with the same name gets name-aabb, where aabb is the first four hex digits of its node_id (auto-assign) or the last octet of its tunnel IPv4 (static). The rename is logged.
DNS takeover
The stub listens on dns_listen (default 127.0.0.1:53).
- gfwlist / chinadns — Magic DNS is answered first, then the existing split-DNS logic. The system resolver is already pointed at the proxy.
- full mode — the client starts a forwarding stub: Magic names are local, everything else goes to
dns_local.set_dns(default on) points the system resolver at that stub, the same way policy modes do.
This is a behaviour change for full mode: with default Magic DNS the client now takes over the system resolver. Opt out with --no-magic-dns (restore today's full-mode behaviour) or --no-set-dns (leave the stub on 127.0.0.1:53 and configure DNS yourself).
--nat servers ignore name adverts. Clients still start the stub if Magic DNS is on, but the table stays empty.
Operator knobs
| JSON field | CLI flag | Default | Meaning |
|---|---|---|---|
hostname | --hostname | sanitized OS hostname | Announced / published name |
magic_dns | --magic-dns / --no-magic-dns | true | Enable the name advert + local stub |
magic_dns_suffix | --magic-dns-suffix | svpn | Zone suffix |
hostname is not carried in a URI / QR code — cloning a share must not clone identity, same as node_id.
The desktop app accepts these keys on a profile (a profile file is a client config) and exposes hostname in the editor. Leave it blank to keep the OS-hostname default.
Validating
From one client, after another is up:
dig @127.0.0.1 pi.svpn A
dig @127.0.0.1 pi.svpn AAAA
ping -c3 pi
ssh user@pi.svpnThe server logs every grant, collision-rename, withdrawal, and expiry. Names expire with lease_ttl_secs (default 120 s) when the advertiser goes quiet; they come back on the next keepalive (15 s by default).
This flow is exercised end to end in CI (docker/run-e2e-magicdns.sh): a hub named vpn and two auto clients named laptop and pi.
Wire format
name advert : 00 05 flags ip4[4] ip6[16] nlen name[nlen]
peer push : 00 06 flags count { eflags ip4[4] ip6[16] nlen name[nlen] }*See the wire protocol. Old peers drop unknown types, so the extension is wire-compatible both ways.