Mesh DNS opt-in · default off
Every node already floods link-state advertisements carrying its name and the tunnel prefixes it serves — which means every node already holds a distributed, self-healing, eventually-consistent name→address database. Mesh DNS is a presentation layer on top of it: each atlasd runs a small local resolver bound to its own tunnel address, answering <node_name>.atlas straight from its own copy of the LSDB, with split-horizon overrides and per-suffix forwarding for everything else. There is no DNS server anywhere in the mesh — resolution is per-node and local, exactly like the tunnel itself: no controller, no coordination server, no reachback required.
The LSDB is already a name database
Overlay products that offer "magic" names (Tailscale's MagicDNS is the familiar example) run a local resolver too — but populate it from their coordination server. Atlas populates its resolver from the routing protocol the mesh already runs: the same LSAs that tell SPF where a node's prefixes live also say what the node is called. Same UX; no control plane in the path. The consequences fall out for free:
- Resolution never leaves the node. A query for a mesh name is answered from local memory — it does not cross a link, a relay, or an uplink. There is nothing to reach, so there is nothing whose outage can break naming.
- Partitions keep their names. Split a mesh in half and each half keeps resolving its own half's names, because each half has its own LSDB. A coordination-server design cannot even learn the map without its server.
- Names track topology. A node that joins is resolvable as soon as its LSA floods; a node that leaves stops resolving when its advertisement ages out. Short TTLs (below) keep client caches honest.
The [dns] table
The whole surface, with defaults:
/etc/atlas/config.toml[dns]
enabled = false # default off
listen = ["10.0.100.1:53"] # default: [interface].address port 53 when enabled.
# config check HARD-REFUSES 0.0.0.0/[::] (open-resolver
# / amplification risk); warns if not tunnel or loopback.
domain = "atlas" # zone suffix; FQDN = <node_name>.<domain>
mode = "split" # "split" (mesh zone local, rest proxied upstream)
# | "mesh-only" (EMCON: out-of-zone → REFUSED, nothing
# ever leaves on an uplink)
upstream = ["9.9.9.9", "149.112.112.112"] # default Quad9 (matches existing convention)
upstream_timeout_ms = 800 # hard deadline; expiry → SERVFAIL, never a hang
serve_reverse = true # PTR zones for our tunnel prefixes
[[dns.override]] # split-horizon: authoritative answer for this exact
name = "video.acme.com" # name when queried over the tunnel; outside, public
a = ["10.0.100.7"] # DNS answers. aaaa = [...] and cname = "..." too.
[[dns.forward]] # per-suffix forwarding (customer's internal resolver,
suffix = "corp.acme.com" # reachable only over the tunnel)
servers = ["10.0.100.53"]
| Key | Default | Behavior |
|---|---|---|
enabled | false | Master switch. Off means no socket is opened and nothing anywhere else in the daemon changes — the feature is invisible until you turn it on. |
listen | [interface] address, port 53 | Addresses the resolver binds — UDP and TCP on each. atlasd config check hard-refuses wildcard binds (0.0.0.0, [::]) as an open-resolver/amplification risk, and warns when an address is neither the tunnel nor loopback. Refusing to start beats starting wrong. |
domain | "atlas" | The zone suffix. A node's FQDN is <node_name>.<domain> — gcs.atlas, or isr01.narva.atlas when the name itself carries dots. |
mode | "split" | split: the mesh zone is answered locally, everything else is proxied to upstream. mesh-only: out-of-zone queries get REFUSED immediately and nothing is ever forwarded — the EMCON posture. |
upstream | Quad9 (9.9.9.9, 149.112.112.112) | Where out-of-zone queries relay in split mode. Point it at your own infrastructure if you have opinions; the default matches the conventions used elsewhere in the daemon. |
upstream_timeout_ms | 800 | Hard per-query deadline on the upstream relay. Expiry returns SERVFAIL to the client — a dead upstream produces a fast, honest error, never a hang. |
serve_reverse | true | Serve PTR zones over the tunnel prefixes, so 10.0.100.7 reverse-resolves to its FQDN — the difference between a topology view full of addresses and one full of names. |
Names, sanitization, conflicts
The name is the node_name every node already advertises in its LSAs (falling back to the hostname), joined to domain. Names may contain dots — node_name = "isr01.narva" with the default domain yields isr01.narva.atlas — and this phase changes nothing on the wire: the resolver is a pure consumer of advertisements the mesh already floods.
Because node_name is free text and DNS labels are not, names are sanitized deterministically: lowercased, with every run of characters outside [a-z0-9-.] collapsed to a single - — "FOB ALPHA" becomes fob-alpha. Each label is capped at 63 characters and the full sanitized name at 128.
Two nodes claiming one name is a configuration mistake the mesh must survive: resolution is first-claim-wins, with a deterministic tiebreak on the lowest NodeId so every node in the mesh gives the same answer, and a loud WARN in the journal so you actually fix it. Name↔key binding — proving a name belongs to a key, not merely detecting the collision — ships with signed zone bundles in a later phase.
What the resolver answers
| Record | Source | Notes |
|---|---|---|
A | The named node's /32 IPv4 prefixes from its LSA. | The common case — one tunnel address per node. |
AAAA | The named node's /128 IPv6 prefixes. | Rare in today's IPv4-inner-tunnel deployments, but fully supported. |
PTR | Reverse zones over the tunnel prefixes (serve_reverse). | Address → FQDN, for topology views, logs and traceroute that read like a map. |
CNAME | [[dns.override]] entries only. | Mesh nodes themselves are always answered with addresses, never aliases. |
SOA / NS | Synthetic, at the zone apex. | Enough for resolver stacks that insist on zone furniture; there is no real zone file anywhere. |
Positive answers carry a 30 s TTL, negative answers 10 s — the LSDB is live and names must track topology, so nothing is cacheable for long. The resolver distinguishes NXDOMAIN (the name does not exist) from NODATA (the name exists but not that record type — an AAAA query for an IPv4-only node returns an empty answer, not an error). Client stacks cache and fall back differently for the two; conflating them is how getaddrinfo stalls end up blamed on the network.
Split versus mesh-only
In the default split mode the resolver answers the mesh zone authoritatively and relays everything else upstream — so you can point a host's resolver at the tunnel address and the whole namespace keeps working. The upstream path is deliberately not a recursive resolver:
- Incoming queries are parsed only far enough to read QNAME and QTYPE (compression-aware) — enough to decide "ours or not", nothing more.
- A query that is not ours is relayed as the raw datagram, on a fresh ephemeral socket per query — source-port randomization by construction — with only the transaction ID rewritten. The answer is relayed back raw, unparsed.
- The wire format is hand-rolled and the dependency count is zero: there is no DNS library to inherit a parser CVE from, because there is almost no parsing.
- Every relayed query carries the
upstream_timeout_msdeadline; expiry returnsSERVFAIL. Fail closed, fail fast.
mesh-only is the EMCON posture: out-of-zone queries are answered REFUSED immediately and nothing is forwarded — no query ever leaves the node on an uplink, and a chatty OS full of telemetry-phoning software can be pointed at the resolver without leaking a single lookup off-mesh.
In both modes the listener speaks UDP and TCP on each configured address and refuses queries from off-tunnel sources.
Overrides and forwards, worked
Two escape hatches cover the names that are not mesh nodes. An override is split-horizon DNS for one exact name: queried over the tunnel it gets your authoritative answer; queried anywhere else, public DNS answers as usual.
[[dns.override]]
name = "video.acme.com"
a = ["10.0.100.7"] # aaaa = [...] and cname = "..." also accepted
A field client resolving video.acme.com through the tunnel now lands on the mesh node actually serving the feed at 10.0.100.7 — while the same laptop on hotel Wi-Fi, resolver pointed elsewhere, gets the public CDN answer. No public zone edit, no split-brain zone files to keep in sync.
A forward delegates a whole suffix to specific servers — typically a customer's internal resolver that is only reachable over the tunnel:
[[dns.forward]]
suffix = "corp.acme.com"
servers = ["10.0.100.53"]
Any name under corp.acme.com is relayed to 10.0.100.53 instead of the public upstream — so intranet names resolve for tunnel clients even though no public server has ever heard of them. Overrides match one exact name and answer authoritatively; forwards catch everything under their suffix; the mesh zone is always answered locally; whatever remains follows mode.
The hosts-file escape hatch
Some environments will not let you touch the resolver configuration at all — locked-down images, minimal containers, the odd distro with a resolver stack all its own. For those, the daemon prints the name database instead of serving it:
$ atlasd dns hosts
10.0.100.1 gcs.atlas
10.0.100.7 isr01.narva.atlas
10.0.100.12 fob-alpha.atlas/etc/hosts-format lines for every currently known mesh name — append them to a hosts file, feed them to a template engine, or cron a refresh. Prefer the live resolver when you can (hosts files do not expire when topology changes); prefer this when touching the resolver is the harder fight.
Operational guidance
- Bind only the tunnel and loopback. The default — the
[interface]address on port 53 — is right for almost everyone.config checkmakes the dangerous spelling impossible and warns about the questionable ones. - Pointing the OS at it is a manual step today. With systemd-resolved, scope the mesh domain to the tunnel interface rather than replacing the global resolver:
resolvectl dns nexus0 10.0.100.1andresolvectl domain nexus0 '~atlas'sends only mesh-zone queries to Atlas and leaves everything else untouched. Watch NetworkManager and resolved on bearer events — both are happy to rewrite resolver state when links flap, which is exactly when an Atlas deployment is busiest. First-class resolved integration is a roadmap item; until then, treat resolver wiring as part of your deployment automation, next to the systemd unit. - Android: the app's engine port does not yet plumb the resolver into
VpnServiceDNS — mesh names on Android are future wiring, not a current feature. - EMCON deployments: run
mode = "mesh-only"and the resolver becomes provably silent off-mesh — the correct default wherever emission control or exfiltration surface matters more than resolving the public internet.
Security posture, honestly
- It cannot be an open resolver. The resolver never binds a wildcard address (config validation hard-refuses it) and refuses recursion for off-tunnel sources — there is no configuration in which it participates in a DNS amplification attack.
- No query ever crosses a relay. Mesh relaying is hop-by-hop encrypted, so a centralized in-mesh resolver would have exposed every lookup to relay operators. Per-node local resolution makes that structurally impossible rather than merely discouraged.
- No cross-tenant surface. The LSDB only ever contains the mesh you are enrolled in — there is no shared namespace, no global registry, and nothing to enumerate from outside the tunnel.
Partition and failure behavior
The design goal, stated plainly: names that survive the loss of the thing that hands out names — because nothing hands out names. Partition a mesh and each side keeps resolving every node it can still route to, from its own LSDB; when the partition heals, the databases converge and so do the answers. A departed node's name disappears when its advertisement ages out, and the 30 s/10 s TTLs keep client caches from lying much longer than that.
Upstream failure is bounded by construction: a dead — or deliberately denied — upstream costs each out-of-zone query exactly upstream_timeout_ms and returns SERVFAIL — and in-mesh resolution never touches an uplink, so a contested or severed backhaul cannot delay a mesh-name answer by a single millisecond. The failure domains do not overlap, in either direction.
Where this goes next roadmap
Everything above is the current, shipping surface. Three phases follow — presented as direction, not as features you can configure today:
- Structure and tooling: a first-class
sitefield in the LSAs (today, dottednode_names carry site structure by convention), built-in systemd-resolved integration replacing the manualresolvectlstep, anddns doctordiagnostics. - Signed zone bundles: console-authored zones — records, overrides, forwards — signed offline and cached on disk at each node, so a control-plane outage degrades nothing; the same trust pattern as the signed relay map in the traversal stack. This is also where name↔key binding lands, closing the trust caveat above.
- Public delegation for ACME DNS-01 only — enough delegation to answer certificate challenges, bringing real TLS to node dashboards. The trade-off will be stated as plainly then as now: publishing node names publicly (or through certificate-transparency logs) leaks force structure, so the default remains what it is today — names resolve only inside the tunnel.