Files
tailscale/tstest/natlab/vmtest/dns_test.go
T
Brad FitzpatrickandBrad Fitzpatrick c0c453334a ipn/ipnlocal: evict stale node indexes when a delta upsert replaces a peer
When a node is renamed in the admin console, control sends peers a
single MapResponse delta: a PeersChanged entry carrying the full
updated node with its new Name, and no new DNSConfig (MagicDNS
records are computed client-side from peer names). That arrives as a
NodeMutationUpsert, but nodeBackend's upsert path only added the new
node's index entries and never removed the replaced node's, so
nodeByName retained the old name, and nodeByAddr, nodeByKey, and
nodeByStableID could likewise go stale if those fields changed.

Since 7e609b258 the quad-100 resolver serves MagicDNS answers on
demand from those live indexes, so a renamed peer's old name kept
resolving until something rebuilt the indexes from a full netmap,
such as toggling Tailscale off and on.

Evict the replaced node's index entries before adding the new ones.
Also consolidate the natlab DNS coverage into a single TestMagicDNS
that boots one VM and exercises extra records, search domains, and
peer add/rename/remove end to end, injecting the same MapResponse
shapes that production control sends.

Updates tailscale/corp#45631

Change-Id: I8a418317d930ec8ce112f7bd19bfd5778117a65e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-07-27 19:43:11 -07:00

166 lines
5.9 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package vmtest_test
import (
"fmt"
"net/netip"
"strings"
"testing"
"time"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/tstest/natlab/vmtest"
"tailscale.com/tstest/natlab/vnet"
"tailscale.com/types/dnstype"
"tailscale.com/types/key"
)
// TestMagicDNS verifies that control-plane DNS config makes it all the
// way into an Ubuntu guest's OS resolver, and that MagicDNS answers
// track netmap changes. Booting a VM is expensive, so this one test
// covers several scenarios in sequence:
//
// - DNSConfig.ExtraRecords resolve via libc (getent), and search
// domains let bare names resolve, which requires tailscaled to
// have plumbed MagicDNS routes and search domains into
// systemd-resolved.
// - A peer added by control resolves by FQDN and by short name,
// and its IP reverse-resolves (PTR) to its name.
// - A peer renamed by control resolves under its new name only:
// the old name must stop resolving, and PTR must track the new
// name. When a node is renamed in the admin console, the control
// plane sends peers a single MapResponse delta: a PeersChanged
// entry containing the full updated Node with the new Name, and
// notably no new DNSConfig (MagicDNS records are computed
// client-side from peer names). This test injects deltas of
// exactly that shape. It reproduces tailscale/corp#45631, where
// a renamed node's old name kept resolving.
// - A peer removed by control stops resolving.
func TestMagicDNS(t *testing.T) {
env := vmtest.New(t, vmtest.ControlDNS("tailnet.test", &tailcfg.DNSConfig{
Proxied: true,
Domains: []string{"tailnet.test", "record"},
Routes: map[string][]*dnstype.Resolver{
"tailnet.test": nil,
"record": nil,
},
ExtraRecords: []tailcfg.DNSRecord{
{Name: "extratest.record", Type: "A", Value: "1.2.3.4"},
},
}))
node := env.AddNode("node",
env.AddNetwork("2.1.1.1", "192.168.1.1/24", vnet.EasyNAT),
vmtest.OS(vmtest.Ubuntu2404))
env.Start()
dt := &dnsTester{t: t, env: env, node: node}
// ExtraRecords, by full name and via the "record" search domain.
dt.wantResolves("extratest.record", "1.2.3.4")
dt.wantResolves("extratest", "1.2.3.4")
nodeKey := env.Status(node).Self.PublicKey
cs := env.ControlServer()
// Add a peer the way control does, as a PeersChanged delta
// carrying the full node.
peerAddr := netip.MustParsePrefix("100.64.7.7/32")
peer := &tailcfg.Node{
ID: 7777,
StableID: "peer1renamed",
Name: "renamee.tailnet.test.",
User: 7777,
Key: key.NewNode().Public(),
Machine: key.NewMachine().Public(),
DiscoKey: key.NewDisco().Public(),
Addresses: []netip.Prefix{peerAddr},
AllowedIPs: []netip.Prefix{peerAddr},
Hostinfo: (&tailcfg.Hostinfo{OS: "linux", Hostname: "renamee"}).View(),
Cap: tailcfg.CurrentCapabilityVersion,
MachineAuthorized: true,
}
if !cs.AddRawMapResponse(nodeKey, &tailcfg.MapResponse{
PeersChanged: []*tailcfg.Node{peer},
}) {
t.Fatal("AddRawMapResponse(add peer): node not connected")
}
dt.wantResolves("renamee.tailnet.test", "100.64.7.7")
dt.wantResolves("renamee", "100.64.7.7") // via search domain
dt.wantResolves("100.64.7.7", "renamee.tailnet.test")
// Rename the peer, sending the same delta shape production
// control sends: the full node again with only the Name changed.
renamed := peer.Clone()
renamed.Name = "renamed.tailnet.test."
if !cs.AddRawMapResponse(nodeKey, &tailcfg.MapResponse{
PeersChanged: []*tailcfg.Node{renamed},
}) {
t.Fatal("AddRawMapResponse(rename peer): node not connected")
}
dt.wantResolves("renamed.tailnet.test", "100.64.7.7")
dt.wantResolves("renamed", "100.64.7.7")
dt.wantResolves("100.64.7.7", "renamed.tailnet.test")
dt.wantNXDOMAIN("renamee.tailnet.test")
dt.wantNXDOMAIN("renamee")
// Remove the peer; its name must stop resolving.
if !cs.AddRawMapResponse(nodeKey, &tailcfg.MapResponse{
PeersRemoved: []tailcfg.NodeID{peer.ID},
}) {
t.Fatal("AddRawMapResponse(remove peer): node not connected")
}
dt.wantNXDOMAIN("renamed.tailnet.test")
}
// dnsTester asserts DNS state in a guest via libc lookups (getent),
// retrying for a bit because tailscaled applies netmap and DNS config
// changes asynchronously.
type dnsTester struct {
t *testing.T
env *vmtest.Env
node *vmtest.Node
}
// wantResolves waits until name resolves and its answer contains want.
// With an IP address as name, getent does a reverse (PTR) lookup and
// want is the expected hostname. It flushes systemd-resolved's cache
// before each attempt so it tests tailscaled's resolver rather than a
// previously cached answer.
func (dt *dnsTester) wantResolves(name, want string) {
dt.t.Helper()
if err := tstest.WaitFor(30*time.Second, func() error {
dt.env.SSHExec(dt.node, "resolvectl flush-caches")
out, err := dt.env.SSHExec(dt.node, "getent hosts "+name)
if err != nil {
return fmt.Errorf("getent hosts %s: %v (%s)", name, err, strings.TrimSpace(out))
}
if !strings.Contains(out, want) {
return fmt.Errorf("getent hosts %s = %q, want it to contain %q", name, strings.TrimSpace(out), want)
}
return nil
}); err != nil {
out, _ := dt.env.SSHExec(dt.node, "resolvectl status; cat /etc/resolv.conf")
dt.t.Fatalf("%v\nresolver state:\n%s", err, out)
}
}
// wantNXDOMAIN waits until name no longer resolves. It flushes
// systemd-resolved's cache before each attempt so it tests
// tailscaled's resolver rather than a previously cached answer.
func (dt *dnsTester) wantNXDOMAIN(name string) {
dt.t.Helper()
if err := tstest.WaitFor(30*time.Second, func() error {
dt.env.SSHExec(dt.node, "resolvectl flush-caches")
out, err := dt.env.SSHExec(dt.node, "getent hosts "+name)
if err == nil {
return fmt.Errorf("%s still resolves: %q", name, strings.TrimSpace(out))
}
return nil
}); err != nil {
dt.t.Fatal(err)
}
}