From 4c91f90776317bcfa5fa7fa3995d5a3376b09999 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Fri, 13 Mar 2026 20:51:22 +0000 Subject: [PATCH] tstest/integration: add userspace-networking + proxymap WhoIs integration test Before sending a fix for #18991, this adds an integration test that locks in that the proxymap WhoIs code works with two nodes running as different users, with the second node running a localhost service and able to use its local tailscaled to identify a Tailscale connection from the other tailscaled. Updates #18991 Change-Id: I6fbb0810204d77d2ac558f0cc786b73e3248d031 Signed-off-by: Brad Fitzpatrick --- tstest/integration/whois_test.go | 152 +++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tstest/integration/whois_test.go diff --git a/tstest/integration/whois_test.go b/tstest/integration/whois_test.go new file mode 100644 index 000000000..b4e99a547 --- /dev/null +++ b/tstest/integration/whois_test.go @@ -0,0 +1,152 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package integration + +import ( + "context" + "errors" + "fmt" + "net" + "testing" + "time" + + "tailscale.com/tstest" +) + +// TestUserspaceWhoIsProxyMap verifies that WhoIs lookups work via the +// proxymap in userspace-networking mode. It sets up two nodes (n1 and +// n2), starts a TCP listener on localhost, and has n1 connect to n2's +// Tailscale IP on the listener's port via "tailscale nc". Node n2's +// netstack forwards the connection to localhost, and the listener +// calls WhoIs on n2's LocalAPI to identify the remote peer as n1. +func TestUserspaceWhoIsProxyMap(t *testing.T) { + tstest.Shard(t) + tstest.Parallel(t) + env := NewTestEnv(t) + + n1 := NewTestNode(t, env) + d1 := n1.StartDaemon() + + n2 := NewTestNode(t, env) + d2 := n2.StartDaemon() + + n1.AwaitListening() + n2.AwaitListening() + n1.MustUp() + n2.MustUp() + n1.AwaitRunning() + n2.AwaitRunning() + + // Wait for n1 to see n2 as a peer. + if err := tstest.WaitFor(10*time.Second, func() error { + st := n1.MustStatus() + if len(st.Peer) == 0 { + return errors.New("no peers") + } + return nil + }); err != nil { + t.Fatal(err) + } + + // Verify the two nodes have different users. If they were the + // same user, a WhoIs hit could pass trivially. + st1 := n1.MustStatus() + st2 := n2.MustStatus() + if st1.Self.UserID == st2.Self.UserID { + t.Fatalf("n1 and n2 have the same UserID %v; want different users", st1.Self.UserID) + } + t.Logf("n1: UserID=%v", st1.Self.UserID) + t.Logf("n2: UserID=%v", st2.Self.UserID) + + n2IP := n2.AwaitIP4() + t.Logf("n2 IP: %v", n2IP) + + // Start a TCP listener on localhost:0. When n1 connects to n2's + // Tailscale IP on this port, n2's netstack (userspace networking) + // will forward the connection to 127.0.0.1:. The listener + // uses n2's LocalAPI WhoIs to identify the connecting peer. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + t.Logf("listener on port %d", port) + + type result struct { + msg string + err error + } + resultCh := make(chan result, 1) + + go func() { + conn, err := ln.Accept() + if err != nil { + resultCh <- result{err: fmt.Errorf("accept: %w", err)} + return + } + defer conn.Close() + + // The RemoteAddr is 127.0.0.1:, the local side of + // n2's netstack dial. WhoIs on n2 should resolve this via the + // proxymap to n1's Tailscale identity. + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + who, err := n2.LocalClient().WhoIs(ctx, conn.RemoteAddr().String()) + if err != nil { + resultCh <- result{err: fmt.Errorf("WhoIs(%q): %w", conn.RemoteAddr(), err)} + return + } + if who.Node == nil { + resultCh <- result{err: errors.New("WhoIs returned nil Node")} + return + } + if who.UserProfile == nil { + resultCh <- result{err: errors.New("WhoIs returned nil UserProfile")} + return + } + + msg := fmt.Sprintf("Hello, %s (%v %v)!", + who.UserProfile.LoginName, who.Node.Name, who.Node.ID) + conn.Write([]byte(msg)) + resultCh <- result{msg: msg} + }() + + // Use "tailscale nc" on n1 to connect to n2's Tailscale IP on + // the listener port. This goes through n1's tailscaled, over + // wireguard to n2's netstack, which dials localhost:. + // + // We need to keep stdin open so nc doesn't exit before reading + // the server's response (nc returns on the first goroutine to + // complete: stdin→conn or conn→stdout). + cmd := n1.TailscaleForOutput("nc", n2IP.String(), fmt.Sprint(port)) + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := cmd.Output() + stdin.Close() + if err != nil { + t.Fatalf("tailscale nc: %v", err) + } + + // Verify the listener goroutine completed without error. + r := <-resultCh + if r.err != nil { + t.Fatal(r.err) + } + + got := string(out) + if got != r.msg { + t.Fatalf("nc output %q doesn't match server-sent message %q", got, r.msg) + } + const wantPrefix = "Hello, user-1@fake-control.example.net (" + if len(got) < len(wantPrefix) || got[:len(wantPrefix)] != wantPrefix { + t.Errorf("got %q, want prefix %q", got, wantPrefix) + } + t.Logf("response: %s", got) + + d1.MustCleanShutdown(t) + d2.MustCleanShutdown(t) +}