Skip to content

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.

text
  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" → .5

Setup ​

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.

json
{
  "server": "vpn.example.com:8388",
  "password": "correct horse battery staple",
  "hostname": "laptop"
}
bash
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 pi

Omit 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 ​

FormExampleNotes
Bare labelpiWorks when the stub is the system resolver
Suffixedpi.svpnAlways unique to the Magic DNS zone
Servervpn.svpnServer 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 intercept ​

Host DNS is intercepted on the TUN (IPv4 UDP/53). Magic names are answered from the local peer table; everything else follows the mode's split-DNS logic (or, in full mode, is forwarded to dns_local). The OS resolver is not rewritten.

An optional UDP stub still binds dns_listen (default 127.0.0.1:53) so operators can query it directly. Opt out of Magic DNS with --no-magic-dns.

--nat servers ignore name adverts. Clients still start the stub if Magic DNS is on, but the table stays empty.

Operator knobs ​

JSON fieldCLI flagDefaultMeaning
hostname--hostnamesanitized OS hostnameAnnounced / published name
magic_dns--magic-dns / --no-magic-dnstrueEnable the name advert + local stub
magic_dns_suffix--magic-dns-suffixsvpnZone 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:

bash
dig @127.0.0.1 pi.svpn A
dig @127.0.0.1 pi.svpn AAAA
ping -c3 pi
ssh user@pi.svpn

The 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 ​

text
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.

Released under the MIT License.