tsnet: add test for advertising multiple Services

This is a regression test for #19166, in which it was discovered that
after calling Server.ListenService for multiple Services, only the
Service from the most recent call would be advertised.

The bug was fixed in 99f8039101

Updates #19166

Signed-off-by: Harry Harpham <harry@tailscale.com>
This commit is contained in:
Harry Harpham
2026-03-29 13:42:07 -06:00
parent 6f0ca946c6
commit fed0df6498

View File

@@ -30,6 +30,7 @@
"reflect"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
@@ -59,6 +60,7 @@
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/mak"
"tailscale.com/util/must"
)
@@ -940,6 +942,93 @@ func TestFunnelClose(t *testing.T) {
})
}
// setUpServiceState performs all necessary state setup for testing with a
// Tailscale Service. When this function returns, the host will be able to
// advertise a Service (via [Server.ListenService]) and the client will be able
// to dial the Service via the Service name.
//
// extraSetup, when non-nil, can be used to perform additional state setup and
// this state will be observable by client and host when this function returns.
func setUpServiceState(t *testing.T, name, ip string, host, client *Server,
control *testcontrol.Server, extraSetup func(*testing.T, *testcontrol.Server)) {
t.Helper()
serviceName := tailcfg.ServiceName(name)
must.Do(serviceName.Validate())
// The Service host must have the 'service-host' capability, which
// is a mapping from the Service name to the Service VIP.
cm := host.lb.NetMap().SelfNode.CapMap()
svcIPMap := make(tailcfg.ServiceIPMappings)
if cm.Contains(tailcfg.NodeAttrServiceHost) {
parsed := must.Get(tailcfg.UnmarshalNodeCapViewJSON[tailcfg.ServiceIPMappings](cm, tailcfg.NodeAttrServiceHost))
if len(parsed) != 1 {
t.Fatalf("expected only one capability for %v, got %d", tailcfg.NodeAttrServiceHost, len(parsed))
}
svcIPMap = parsed[0]
}
svcIPMap[serviceName] = []netip.Addr{netip.MustParseAddr(ip)}
svcIPMapJSON := must.Get(json.Marshal(svcIPMap))
newCM := cm.AsMap()
mak.Set(&newCM, tailcfg.NodeAttrServiceHost, []tailcfg.RawMessage{tailcfg.RawMessage(svcIPMapJSON)})
control.SetNodeCapMap(host.lb.NodeKey(), newCM)
// The Service host must be allowed to advertise the Service VIP.
subnetRoutes := []netip.Prefix{netip.MustParsePrefix(ip + `/32`)}
selfAddresses := host.lb.NetMap().SelfNode.Addresses()
for _, existingRoute := range host.lb.NetMap().SelfNode.AllowedIPs().All() {
if views.SliceContains(selfAddresses, existingRoute) {
continue
}
subnetRoutes = append(subnetRoutes, existingRoute)
}
control.SetSubnetRoutes(host.lb.NodeKey(), subnetRoutes)
// The Service host must be a tagged node (any tag will do).
serviceHostNode := control.Node(host.lb.NodeKey())
serviceHostNode.Tags = append(serviceHostNode.Tags, "some-tag")
control.UpdateNode(serviceHostNode)
// The service client must accept routes advertised by other nodes
// (RouteAll is equivalent to --accept-routes).
must.Get(client.localClient.EditPrefs(t.Context(), &ipn.MaskedPrefs{
RouteAllSet: true,
Prefs: ipn.Prefs{
RouteAll: true,
},
}))
// Do the test's extra setup before configuring DNS. This allows
// us to use the configured DNS records as sentinel values when
// waiting for all of this setup to be visible to test nodes.
if extraSetup != nil {
extraSetup(t, control)
}
// Set up DNS for our Service.
control.AddDNSRecords(tailcfg.DNSRecord{
Name: serviceName.WithoutPrefix() + "." + control.MagicDNSDomain,
Value: ip,
})
// Wait until both nodes have up-to-date netmaps before
// proceeding with the test.
netmapUpToDate := func(nm *netmap.NetworkMap) bool {
return nm != nil && slices.ContainsFunc(nm.DNS.ExtraRecords, func(r tailcfg.DNSRecord) bool {
return r.Value == ip
})
}
waitForLatestNetmap := func(t *testing.T, s *Server) {
t.Helper()
w := must.Get(s.localClient.WatchIPNBus(t.Context(), ipn.NotifyInitialNetMap))
defer w.Close()
for n := must.Get(w.Next()); !netmapUpToDate(n.NetMap); n = must.Get(w.Next()) {
}
}
waitForLatestNetmap(t, client)
waitForLatestNetmap(t, host)
}
func TestListenService(t *testing.T) {
tstest.Shard(t)
@@ -1207,81 +1296,19 @@ func TestListenService(t *testing.T) {
// We run each test with and without a TUN device ([Server.Tun]).
// Note that this TUN device is distinct from TUN mode for Services.
doTest := func(t *testing.T, withTUNDevice bool) {
ctx := t.Context()
lt := setupTwoClientTest(t, withTUNDevice)
serviceHost := lt.s2
serviceClient := lt.s1
control := lt.control
const serviceName = tailcfg.ServiceName("svc:foo")
const serviceName = "svc:foo"
const serviceVIP = "100.11.22.33"
// == Set up necessary state in our mock ==
setUpServiceState(t, serviceName, serviceVIP,
serviceHost, serviceClient, lt.control, tt.extraSetup)
// The Service host must have the 'service-host' capability, which
// is a mapping from the Service name to the Service VIP.
cm := serviceHost.lb.NetMap().SelfNode.CapMap().AsMap()
mak.Set(&cm, tailcfg.NodeAttrServiceHost, []tailcfg.RawMessage{
tailcfg.RawMessage(fmt.Sprintf(`{"%s": ["%s"]}`, serviceName, serviceVIP)),
})
control.SetNodeCapMap(serviceHost.lb.NodeKey(), cm)
// The Service host must be allowed to advertise the Service VIP.
control.SetSubnetRoutes(serviceHost.lb.NodeKey(), []netip.Prefix{
netip.MustParsePrefix(serviceVIP + `/32`),
})
// The Service host must be a tagged node (any tag will do).
serviceHostNode := control.Node(serviceHost.lb.NodeKey())
serviceHostNode.Tags = append(serviceHostNode.Tags, "some-tag")
control.UpdateNode(serviceHostNode)
// The service client must accept routes advertised by other nodes
// (RouteAll is equivalent to --accept-routes).
must.Get(serviceClient.localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
RouteAllSet: true,
Prefs: ipn.Prefs{
RouteAll: true,
},
}))
// Do the test's extra setup before configuring DNS. This allows
// us to use the configured DNS records as sentinel values when
// waiting for all of this setup to be visible to test nodes.
if tt.extraSetup != nil {
tt.extraSetup(t, control)
}
// Set up DNS for our Service.
control.AddDNSRecords(tailcfg.DNSRecord{
Name: serviceName.WithoutPrefix() + "." + control.MagicDNSDomain,
Value: serviceVIP,
})
// Wait until both nodes have up-to-date netmaps before
// proceeding with the test.
netmapUpToDate := func(nm *netmap.NetworkMap) bool {
return nm != nil && slices.ContainsFunc(nm.DNS.ExtraRecords, func(r tailcfg.DNSRecord) bool {
return r.Value == serviceVIP
})
}
waitForLatestNetmap := func(t *testing.T, s *Server) {
t.Helper()
w := must.Get(s.localClient.WatchIPNBus(t.Context(), ipn.NotifyInitialNetMap))
defer w.Close()
for n := must.Get(w.Next()); !netmapUpToDate(n.NetMap); n = must.Get(w.Next()) {
}
}
waitForLatestNetmap(t, serviceClient)
waitForLatestNetmap(t, serviceHost)
// == Done setting up mock state ==
// Start the Service listeners.
listeners := make([]*ServiceListener, 0, len(tt.modes))
for _, input := range tt.modes {
ln := must.Get(serviceHost.ListenService(serviceName.String(), input))
ln := must.Get(serviceHost.ListenService(serviceName, input))
defer ln.Close()
listeners = append(listeners, ln)
}
@@ -1312,31 +1339,18 @@ func TestListenService(t *testing.T) {
t.Run("duplicate_listeners", func(t *testing.T) {
ctx := t.Context()
const serviceName = "svc:foo"
controlURL, control := startControl(t)
serviceHost, _, _ := startServer(t, ctx, controlURL, "service-host")
serviceClient, _, _ := startServer(t, ctx, controlURL, "service-client")
// Service hosts must be a tagged node (any tag will do).
serviceHostNode := control.Node(serviceHost.lb.NodeKey())
serviceHostNode.Tags = append(serviceHostNode.Tags, "some-tag")
control.UpdateNode(serviceHostNode)
setUpServiceState(t, serviceName, "1.2.3.4", serviceHost, serviceClient, control, nil)
// Wait for an up-to-date netmap before proceeding with the test.
netmapUpToDate := func(nm *netmap.NetworkMap) bool {
return nm != nil && nm.SelfNode.IsTagged()
}
waitForLatestNetmap := func(t *testing.T, s *Server) {
t.Helper()
w := must.Get(s.localClient.WatchIPNBus(t.Context(), ipn.NotifyInitialNetMap))
defer w.Close()
for n := must.Get(w.Next()); !netmapUpToDate(n.NetMap); n = must.Get(w.Next()) {
}
}
waitForLatestNetmap(t, serviceHost)
ln := must.Get(serviceHost.ListenService("svc:foo", ServiceModeTCP{Port: 8080}))
ln := must.Get(serviceHost.ListenService(serviceName, ServiceModeTCP{Port: 8080}))
defer ln.Close()
ln, err := serviceHost.ListenService("svc:foo", ServiceModeTCP{Port: 8080})
ln, err := serviceHost.ListenService(serviceName, ServiceModeTCP{Port: 8080})
if ln != nil {
ln.Close()
}
@@ -1345,7 +1359,7 @@ func TestListenService(t *testing.T) {
}
// An HTTP listener on the same port should also collide
ln, err = serviceHost.ListenService("svc:foo", ServiceModeHTTP{Port: 8080})
ln, err = serviceHost.ListenService(serviceName, ServiceModeHTTP{Port: 8080})
if ln != nil {
ln.Close()
}
@@ -1353,6 +1367,62 @@ func TestListenService(t *testing.T) {
t.Fatal("expected error for redundant listener")
}
})
t.Run("multiple_services", func(t *testing.T) {
const numberServices = 10
const port = 80
lt := setupTwoClientTest(t, false)
serviceHost := lt.s2
serviceClient := lt.s1
names := make([]string, numberServices)
fqdns := make([]string, numberServices)
for i := range numberServices {
serviceName := "svc:foo" + strconv.Itoa(i+1)
serviceIP := `11.22.33.` + strconv.Itoa(i+1)
setUpServiceState(t, serviceName, serviceIP, serviceHost, serviceClient, lt.control, nil)
ln := must.Get(serviceHost.ListenService(serviceName, ServiceModeTCP{Port: port}))
defer ln.Close()
names[i] = serviceName
fqdns[i] = ln.FQDN
go func() {
// Accept a single connection, echo, then return.
conn, err := ln.Accept()
if err != nil {
t.Errorf("accept error from %v: %v", serviceName, err)
return
}
defer conn.Close()
if _, err := io.Copy(conn, conn); err != nil {
t.Errorf("copy error from %v: %v", serviceName, err)
}
}()
}
for i := range numberServices {
msg := []byte("hello, " + fqdns[i])
conn := must.Get(serviceClient.Dial(t.Context(), "tcp", fqdns[i]+":"+strconv.Itoa(port)))
defer conn.Close()
must.Get(conn.Write(msg))
buf := make([]byte, len(msg))
n := must.Get(conn.Read(buf))
if !bytes.Equal(buf[:n], msg) {
t.Fatalf("did not receive expected message:\n\tgot: %s\n\twant: %s\n", buf[:n], msg)
}
}
// Each of the Services should be advertised by our Service host.
advertised := serviceHost.lb.Prefs().AdvertiseServices()
for _, name := range names {
if !views.SliceContains(advertised, name) {
t.Log("advertised Services:", advertised)
t.Fatalf("did not find %q in advertised Services", name)
}
}
})
}
func TestListenServiceClose(t *testing.T) {
@@ -1454,28 +1524,14 @@ func TestListenServiceClose(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := t.Context()
const serviceName = "svc:foo"
controlURL, control := startControl(t)
s, _, _ := startServer(t, ctx, controlURL, "service-host")
serviceHost, _, _ := startServer(t, ctx, controlURL, "service-host")
serviceClient, _, _ := startServer(t, ctx, controlURL, "service-client")
setUpServiceState(t, serviceName, "1.2.3.4", serviceHost, serviceClient, control, nil)
// Service hosts must be a tagged node (any tag will do).
serviceHostNode := control.Node(s.lb.NodeKey())
serviceHostNode.Tags = append(serviceHostNode.Tags, "some-tag")
control.UpdateNode(serviceHostNode)
// Wait for an up-to-date netmap before proceeding with the test.
netmapUpToDate := func(nm *netmap.NetworkMap) bool {
return nm != nil && nm.SelfNode.IsTagged()
}
waitForLatestNetmap := func(t *testing.T, s *Server) {
t.Helper()
w := must.Get(s.localClient.WatchIPNBus(t.Context(), ipn.NotifyInitialNetMap))
defer w.Close()
for n := must.Get(w.Next()); !netmapUpToDate(n.NetMap); n = must.Get(w.Next()) {
}
}
waitForLatestNetmap(t, s)
tt.run(t, s)
tt.run(t, serviceHost)
})
}