tstest/natlab: add test for no control and rotated disco key (#18261)

Updates #12639

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
This commit is contained in:
Claus Lensbøl
2026-03-05 16:00:36 -05:00
committed by GitHub
parent c17ec8ce1c
commit 9657a93217
4 changed files with 140 additions and 29 deletions
+52 -4
View File
@@ -25,6 +25,7 @@ import (
"golang.org/x/mod/modfile" "golang.org/x/mod/modfile"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"tailscale.com/client/tailscale" "tailscale.com/client/tailscale"
"tailscale.com/envknob"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/syncs" "tailscale.com/syncs"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
@@ -133,6 +134,24 @@ func easyAnd6(c *vnet.Config) *vnet.Node {
vnet.EasyNAT)) vnet.EasyNAT))
} }
// easyNoControlDiscoRotate sets up a node with easy NAT, cuts traffic to
// control after connecting, and then rotates the disco key to simulate a newly
// started node (from a disco perspective).
func easyNoControlDiscoRotate(c *vnet.Config) *vnet.Node {
n := c.NumNodes() + 1
nw := c.AddNetwork(
fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
fmt.Sprintf("192.168.%d.1/24", n),
vnet.EasyNAT)
nw.SetPostConnectControlBlackhole(true)
return c.AddNode(
vnet.TailscaledEnv{
Key: "TS_USE_CACHED_NETMAP",
Value: "true",
},
vnet.RotateDisco, vnet.PreICMPPing, nw)
}
func v6AndBlackholedIPv4(c *vnet.Config) *vnet.Node { func v6AndBlackholedIPv4(c *vnet.Config) *vnet.Node {
n := c.NumNodes() + 1 n := c.NumNodes() + 1
nw := c.AddNetwork( nw := c.AddNetwork(
@@ -364,7 +383,9 @@ func (nt *natTest) runTest(addNode ...addNodeFunc) pingRoute {
var clients []*vnet.NodeAgentClient var clients []*vnet.NodeAgentClient
for _, n := range nodes { for _, n := range nodes {
clients = append(clients, nt.vnet.NodeAgentClient(n)) client := nt.vnet.NodeAgentClient(n)
n.SetClient(client)
clients = append(clients, client)
} }
sts := make([]*ipnstate.Status, len(nodes)) sts := make([]*ipnstate.Status, len(nodes))
@@ -415,7 +436,27 @@ func (nt *natTest) runTest(addNode ...addNodeFunc) pingRoute {
return "" return ""
} }
pingRes, err := ping(ctx, t, clients[0], sts[1].Self.TailscaleIPs[0]) preICMPPing := false
for _, node := range c.Nodes() {
node.Network().PostConnectedToControl()
if err := node.PostConnectedToControl(ctx); err != nil {
t.Fatalf("post control error: %s", err)
}
if node.PreICMPPing() {
preICMPPing = true
}
}
// Should we send traffic across the nodes before starting disco?
// For nodes that rotated disco keys after control going away.
if preICMPPing {
_, err := ping(ctx, t, clients[0], sts[1].Self.TailscaleIPs[0], tailcfg.PingICMP)
if err != nil {
t.Fatalf("ICMP ping failure: %v", err)
}
}
pingRes, err := ping(ctx, t, clients[0], sts[1].Self.TailscaleIPs[0], tailcfg.PingDisco)
if err != nil { if err != nil {
t.Fatalf("ping failure: %v", err) t.Fatalf("ping failure: %v", err)
} }
@@ -450,12 +491,12 @@ const (
routeNil pingRoute = "nil" // *ipnstate.PingResult is nil routeNil pingRoute = "nil" // *ipnstate.PingResult is nil
) )
func ping(ctx context.Context, t testing.TB, c *vnet.NodeAgentClient, target netip.Addr) (*ipnstate.PingResult, error) { func ping(ctx context.Context, t testing.TB, c *vnet.NodeAgentClient, target netip.Addr, pType tailcfg.PingType) (*ipnstate.PingResult, error) {
var lastRes *ipnstate.PingResult var lastRes *ipnstate.PingResult
for n := range 10 { for n := range 10 {
t.Logf("ping attempt %d to %v ...", n+1, target) t.Logf("ping attempt %d to %v ...", n+1, target)
pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second) pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
pr, err := c.PingWithOpts(pingCtx, target, tailcfg.PingDisco, tailscale.PingOpts{}) pr, err := c.PingWithOpts(pingCtx, target, pType, tailscale.PingOpts{})
cancel() cancel()
if err != nil { if err != nil {
t.Logf("ping attempt %d error: %v", n+1, err) t.Logf("ping attempt %d error: %v", n+1, err)
@@ -529,6 +570,13 @@ func TestEasyEasy(t *testing.T) {
nt.want(routeDirect) nt.want(routeDirect)
} }
func TestTwoEasyNoControlDiscoRotate(t *testing.T) {
envknob.Setenv("TS_USE_CACHED_NETMAP", "1")
nt := newNatTest(t)
nt.runTest(easyNoControlDiscoRotate, easyNoControlDiscoRotate)
nt.want(routeDirect)
}
// Issue tailscale/corp#26438: use learned DERP route as send path of last // Issue tailscale/corp#26438: use learned DERP route as send path of last
// resort // resort
// //
+61 -7
View File
@@ -5,6 +5,7 @@ package vnet
import ( import (
"cmp" "cmp"
"context"
"fmt" "fmt"
"iter" "iter"
"net/netip" "net/netip"
@@ -114,6 +115,10 @@ func (c *Config) AddNode(opts ...any) *Node {
switch o { switch o {
case HostFirewall: case HostFirewall:
n.hostFW = true n.hostFW = true
case RotateDisco:
n.rotateDisco = true
case PreICMPPing:
n.preICMPPing = true
case VerboseSyslog: case VerboseSyslog:
n.verboseSyslog = true n.verboseSyslog = true
default: default:
@@ -137,6 +142,8 @@ type NodeOption string
const ( const (
HostFirewall NodeOption = "HostFirewall" HostFirewall NodeOption = "HostFirewall"
RotateDisco NodeOption = "RotateDisco"
PreICMPPing NodeOption = "PreICMPPing"
VerboseSyslog NodeOption = "VerboseSyslog" VerboseSyslog NodeOption = "VerboseSyslog"
) )
@@ -197,12 +204,15 @@ func (c *Config) AddNetwork(opts ...any) *Network {
// Node is the configuration of a node in the virtual network. // Node is the configuration of a node in the virtual network.
type Node struct { type Node struct {
err error err error
num int // 1-based node number num int // 1-based node number
n *node // nil until NewServer called n *node // nil until NewServer called
client *NodeAgentClient
env []TailscaledEnv env []TailscaledEnv
hostFW bool hostFW bool
rotateDisco bool
preICMPPing bool
verboseSyslog bool verboseSyslog bool
// TODO(bradfitz): this is halfway converted to supporting multiple NICs // TODO(bradfitz): this is halfway converted to supporting multiple NICs
@@ -243,6 +253,33 @@ func (n *Node) SetVerboseSyslog(v bool) {
n.verboseSyslog = v n.verboseSyslog = v
} }
func (n *Node) SetClient(c *NodeAgentClient) {
n.client = c
}
// PostConnectedToControl should be called after the clients have connected to
// control to modify the client behaviour after getting the network maps.
// Currently, the only implemented behavior is rotating disco keys.
func (n *Node) PostConnectedToControl(ctx context.Context) error {
if n.rotateDisco {
if err := n.client.DebugAction(ctx, "rotate-disco-key"); err != nil {
return err
}
}
return nil
}
// PreICMPPing reports whether node should send an ICMP Ping sent before
// the disco ping. This is important for the nodes having rotated their
// disco keys while control is down. Disco pings deliberately does not
// trigger a TSMPDiscoKeyAdvertisement, making the need for other traffic (here
// simlulated as an ICMP ping) needed first. Any traffic could trigger this key
// exchange, the ICMP Ping is used as a handy existing way of sending some
// non-disco traffic.
func (n *Node) PreICMPPing() bool {
return n.preICMPPing
}
// IsV6Only reports whether this node is only connected to IPv6 networks. // IsV6Only reports whether this node is only connected to IPv6 networks.
func (n *Node) IsV6Only() bool { func (n *Node) IsV6Only() bool {
for _, net := range n.nets { for _, net := range n.nets {
@@ -275,10 +312,12 @@ type Network struct {
wanIP6 netip.Prefix // global unicast router in host bits; CIDR is /64 delegated to LAN wanIP6 netip.Prefix // global unicast router in host bits; CIDR is /64 delegated to LAN
wanIP4 netip.Addr // IPv4 WAN IP, if any wanIP4 netip.Addr // IPv4 WAN IP, if any
lanIP4 netip.Prefix lanIP4 netip.Prefix
nodes []*Node nodes []*Node
breakWAN4 bool // whether to break WAN IPv4 connectivity breakWAN4 bool // whether to break WAN IPv4 connectivity
postConnectBlackholeControl bool // whether to break control connectivity after nodes have connected
network *network
svcs set.Set[NetworkService] svcs set.Set[NetworkService]
@@ -310,6 +349,12 @@ func (n *Network) SetBlackholedIPv4(v bool) {
n.breakWAN4 = v n.breakWAN4 = v
} }
// SetPostConnectControlBlackhole sets wether the network should blackhole all
// traffic to the control server after the clients have connected.
func (n *Network) SetPostConnectControlBlackhole(v bool) {
n.postConnectBlackholeControl = v
}
func (n *Network) CanV4() bool { func (n *Network) CanV4() bool {
return n.lanIP4.IsValid() || n.wanIP4.IsValid() return n.lanIP4.IsValid() || n.wanIP4.IsValid()
} }
@@ -325,6 +370,13 @@ func (n *Network) CanTakeMoreNodes() bool {
return len(n.nodes) < 150 return len(n.nodes) < 150
} }
// PostConnectedToControl should be called after the clients have connected to
// the control server to modify network behaviors. Currently the only
// implemented behavior is to conditionally blackhole traffic to control.
func (n *Network) PostConnectedToControl() {
n.network.SetControlBlackholed(n.postConnectBlackholeControl)
}
// NetworkService is a service that can be added to a network. // NetworkService is a service that can be added to a network.
type NetworkService string type NetworkService string
@@ -390,6 +442,8 @@ func (s *Server) initFromConfig(c *Config) error {
} }
netOfConf[conf] = n netOfConf[conf] = n
s.networks.Add(n) s.networks.Add(n)
conf.network = n
if conf.wanIP4.IsValid() { if conf.wanIP4.IsValid() {
if conf.wanIP4.Is6() { if conf.wanIP4.Is6() {
return fmt.Errorf("invalid IPv6 address in wanIP") return fmt.Errorf("invalid IPv6 address in wanIP")
+26 -18
View File
@@ -506,23 +506,24 @@ func (nw networkWriter) write(b []byte) {
} }
type network struct { type network struct {
s *Server s *Server
num int // 1-based num int // 1-based
mac MAC // of router mac MAC // of router
portmap bool portmap bool
lanInterfaceID int lanInterfaceID int
wanInterfaceID int wanInterfaceID int
v4 bool // network supports IPv4 v4 bool // network supports IPv4
v6 bool // network support IPv6 v6 bool // network support IPv6
wanIP6 netip.Prefix // router's WAN IPv6, if any, as a /64. wanIP6 netip.Prefix // router's WAN IPv6, if any, as a /64.
wanIP4 netip.Addr // router's LAN IPv4, if any wanIP4 netip.Addr // router's LAN IPv4, if any
lanIP4 netip.Prefix // router's LAN IP + CIDR (e.g. 192.168.2.1/24) lanIP4 netip.Prefix // router's LAN IP + CIDR (e.g. 192.168.2.1/24)
breakWAN4 bool // break WAN IPv4 connectivity breakWAN4 bool // break WAN IPv4 connectivity
latency time.Duration // latency applied to interface writes blackholeControl bool // blackhole control connectivity
lossRate float64 // probability of dropping a packet (0.0 to 1.0) latency time.Duration // latency applied to interface writes
nodesByIP4 map[netip.Addr]*node // by LAN IPv4 lossRate float64 // probability of dropping a packet (0.0 to 1.0)
nodesByMAC map[MAC]*node nodesByIP4 map[netip.Addr]*node // by LAN IPv4
logf func(format string, args ...any) nodesByMAC map[MAC]*node
logf func(format string, args ...any)
ns *stack.Stack ns *stack.Stack
linkEP *channel.Endpoint linkEP *channel.Endpoint
@@ -578,6 +579,12 @@ func (n *network) MACOfIP(ip netip.Addr) (_ MAC, ok bool) {
return MAC{}, false return MAC{}, false
} }
// SetControlBlackholed sets wether traffic to control should be blackholed for the
// network.
func (n *network) SetControlBlackholed(v bool) {
n.blackholeControl = v
}
type node struct { type node struct {
mac MAC mac MAC
num int // 1-based node number num int // 1-based node number
@@ -1263,7 +1270,8 @@ func (n *network) HandleEthernetPacketForRouter(ep EthernetPacket) {
} }
if toForward && n.s.shouldInterceptTCP(packet) { if toForward && n.s.shouldInterceptTCP(packet) {
if flow.dst.Is4() && n.breakWAN4 { if (flow.dst.Is4() && n.breakWAN4) ||
(n.blackholeControl && fakeControl.Match(flow.dst)) {
// Blackhole the packet. // Blackhole the packet.
return return
} }
+1
View File
@@ -4266,6 +4266,7 @@ func (c *Conn) HandleDiscoKeyAdvertisement(node tailcfg.NodeView, update packet.
// If the key did not change, count it and return. // If the key did not change, count it and return.
if oldDiscoKey.Compare(discoKey) == 0 { if oldDiscoKey.Compare(discoKey) == 0 {
metricTSMPDiscoKeyAdvertisementUnchanged.Add(1) metricTSMPDiscoKeyAdvertisementUnchanged.Add(1)
c.logf("magicsock: disco key did not change for node %v", nodeKey.ShortString())
return return
} }
c.discoInfoForKnownPeerLocked(discoKey) c.discoInfoForKnownPeerLocked(discoKey)