|
|
|
@@ -5,6 +5,7 @@ package vmtest_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/netip"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
@@ -14,19 +15,38 @@ import (
|
|
|
|
|
"tailscale.com/tstest/natlab/vmtest"
|
|
|
|
|
"tailscale.com/tstest/natlab/vnet"
|
|
|
|
|
"tailscale.com/types/dnstype"
|
|
|
|
|
"tailscale.com/types/key"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// TestDNSExtraRecordsSearchDomains verifies that control-plane DNS config
|
|
|
|
|
// makes it all the way into an Ubuntu guest's OS resolver: an ExtraRecords
|
|
|
|
|
// entry ("extratest.record" → 1.2.3.4) resolves via libc (getent), and the
|
|
|
|
|
// "record" search domain lets the bare name "extratest" resolve too. That
|
|
|
|
|
// requires tailscaled to have plumbed MagicDNS routes and search domains
|
|
|
|
|
// into systemd-resolved.
|
|
|
|
|
func TestDNSExtraRecordsSearchDomains(t *testing.T) {
|
|
|
|
|
// 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{"record"},
|
|
|
|
|
Routes: map[string][]*dnstype.Resolver{"record": nil},
|
|
|
|
|
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"},
|
|
|
|
|
},
|
|
|
|
@@ -36,21 +56,110 @@ func TestDNSExtraRecordsSearchDomains(t *testing.T) {
|
|
|
|
|
vmtest.OS(vmtest.Ubuntu2404))
|
|
|
|
|
env.Start()
|
|
|
|
|
|
|
|
|
|
for _, name := range []string{"extratest.record", "extratest"} {
|
|
|
|
|
// Retry for a bit: tailscaled applies the DNS config to
|
|
|
|
|
// systemd-resolved asynchronously after coming up.
|
|
|
|
|
if err := tstest.WaitFor(30*time.Second, func() error {
|
|
|
|
|
out, err := env.SSHExec(node, "getent hosts "+name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("getent hosts %s: %v (%s)", name, err, strings.TrimSpace(out))
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(out, "1.2.3.4") {
|
|
|
|
|
return fmt.Errorf("getent hosts %s = %q, want it to contain 1.2.3.4", name, strings.TrimSpace(out))
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}); err != nil {
|
|
|
|
|
out, _ := env.SSHExec(node, "resolvectl status; cat /etc/resolv.conf")
|
|
|
|
|
t.Fatalf("%v\nresolver state:\n%s", err, out)
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|