From d956af0a3aab6e651f58796bec331f62bad9c95d Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Sun, 5 May 2024 22:36:54 -0700 Subject: [PATCH] chore: add EasyNATDERP tailnet integration test (#13138) --- tailnet/test/integration/integration.go | 148 ++------ tailnet/test/integration/integration_test.go | 108 ++++-- tailnet/test/integration/network.go | 378 +++++++++++++++++-- tailnet/test/integration/suite.go | 31 ++ 4 files changed, 511 insertions(+), 154 deletions(-) create mode 100644 tailnet/test/integration/suite.go diff --git a/tailnet/test/integration/integration.go b/tailnet/test/integration/integration.go index f4d884b36c..ff6552e2d8 100644 --- a/tailnet/test/integration/integration.go +++ b/tailnet/test/integration/integration.go @@ -30,7 +30,6 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/tailnet" ) @@ -40,78 +39,7 @@ var ( Client2ID = uuid.MustParse("00000000-0000-0000-0000-000000000002") ) -type TestTopology struct { - Name string - // SetupNetworking creates interfaces and network namespaces for the test. - // The most simple implementation is NetworkSetupDefault, which only creates - // a network namespace shared for all tests. - SetupNetworking func(t *testing.T, logger slog.Logger) TestNetworking - - // StartServer gets called in the server subprocess. It's expected to start - // the coordinator server in the background and return. - StartServer func(t *testing.T, logger slog.Logger, listenAddr string) - // StartClient gets called in each client subprocess. It's expected to - // create the tailnet.Conn and ensure connectivity to it's peer. - StartClient func(t *testing.T, logger slog.Logger, serverURL *url.URL, myID uuid.UUID, peerID uuid.UUID) *tailnet.Conn - - // RunTests is the main test function. It's called in each of the client - // subprocesses. If tests can only run once, they should check the client ID - // and return early if it's not the expected one. - RunTests func(t *testing.T, logger slog.Logger, serverURL *url.URL, myID uuid.UUID, peerID uuid.UUID, conn *tailnet.Conn) -} - -type TestNetworking struct { - // ServerListenAddr is the IP address and port that the server listens on, - // passed to StartServer. - ServerListenAddr string - // ServerAccessURLClient1 is the hostname and port that the first client - // uses to access the server. - ServerAccessURLClient1 string - // ServerAccessURLClient2 is the hostname and port that the second client - // uses to access the server. - ServerAccessURLClient2 string - - // Networking settings for each subprocess. - ProcessServer TestNetworkingProcess - ProcessClient1 TestNetworkingProcess - ProcessClient2 TestNetworkingProcess -} - -type TestNetworkingProcess struct { - // NetNS to enter. If zero, the current network namespace is used. - NetNSFd int -} - -func SetupNetworkingLoopback(t *testing.T, _ slog.Logger) TestNetworking { - netNSName := "codertest_netns_" - randStr, err := cryptorand.String(4) - require.NoError(t, err, "generate random string for netns name") - netNSName += randStr - - // Create a single network namespace for all tests so we can have an - // isolated loopback interface. - netNSFile, err := createNetNS(netNSName) - require.NoError(t, err, "create network namespace") - t.Cleanup(func() { - _ = netNSFile.Close() - }) - - var ( - listenAddr = "127.0.0.1:8080" - process = TestNetworkingProcess{ - NetNSFd: int(netNSFile.Fd()), - } - ) - return TestNetworking{ - ServerListenAddr: listenAddr, - ServerAccessURLClient1: "http://" + listenAddr, - ServerAccessURLClient2: "http://" + listenAddr, - ProcessServer: process, - ProcessClient1: process, - ProcessClient2: process, - } -} - +// StartServerBasic creates a coordinator and DERP server. func StartServerBasic(t *testing.T, logger slog.Logger, listenAddr string) { coord := tailnet.NewCoordinator(logger) var coordPtr atomic.Pointer[tailnet.Coordinator] @@ -208,42 +136,7 @@ func StartServerBasic(t *testing.T, logger slog.Logger, listenAddr string) { }) } -func basicDERPMap(t *testing.T, serverURL *url.URL) *tailcfg.DERPMap { - portStr := serverURL.Port() - port, err := strconv.Atoi(portStr) - require.NoError(t, err, "parse server port") - - hostname := serverURL.Hostname() - ipv4 := "" - ip, err := netip.ParseAddr(hostname) - if err == nil { - hostname = "" - ipv4 = ip.String() - } - - return &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: { - RegionID: 1, - RegionCode: "test", - RegionName: "test server", - Nodes: []*tailcfg.DERPNode{ - { - Name: "test0", - RegionID: 1, - HostName: hostname, - IPv4: ipv4, - IPv6: "none", - DERPPort: port, - ForceHTTP: true, - InsecureForTests: true, - }, - }, - }, - }, - } -} - +// StartClientBasic creates a client connection to the server. func StartClientBasic(t *testing.T, logger slog.Logger, serverURL *url.URL, myID uuid.UUID, peerID uuid.UUID) *tailnet.Conn { u, err := serverURL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/coordinate", myID.String())) require.NoError(t, err) @@ -284,3 +177,40 @@ func StartClientBasic(t *testing.T, logger slog.Logger, serverURL *url.URL, myID return conn } + +func basicDERPMap(t *testing.T, serverURL *url.URL) *tailcfg.DERPMap { + portStr := serverURL.Port() + port, err := strconv.Atoi(portStr) + require.NoError(t, err, "parse server port") + + hostname := serverURL.Hostname() + ipv4 := "" + ip, err := netip.ParseAddr(hostname) + if err == nil { + hostname = "" + ipv4 = ip.String() + } + + return &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + RegionID: 1, + RegionCode: "test", + RegionName: "test server", + Nodes: []*tailcfg.DERPNode{ + { + Name: "test0", + RegionID: 1, + HostName: hostname, + IPv4: ipv4, + IPv6: "none", + DERPPort: port, + STUNPort: -1, + ForceHTTP: true, + InsecureForTests: true, + }, + }, + }, + }, + } +} diff --git a/tailnet/test/integration/integration_test.go b/tailnet/test/integration/integration_test.go index 1678016c4a..76b57fecae 100644 --- a/tailnet/test/integration/integration_test.go +++ b/tailnet/test/integration/integration_test.go @@ -12,6 +12,8 @@ import ( "os/exec" "os/signal" "runtime" + "strings" + "sync" "syscall" "testing" "time" @@ -66,20 +68,22 @@ func TestMain(m *testing.M) { var topologies = []integration.TestTopology{ { - Name: "BasicLoopback", + Name: "BasicLoopbackDERP", SetupNetworking: integration.SetupNetworkingLoopback, StartServer: integration.StartServerBasic, StartClient: integration.StartClientBasic, - RunTests: func(t *testing.T, log slog.Logger, serverURL *url.URL, myID, peerID uuid.UUID, conn *tailnet.Conn) { - // Test basic connectivity - peerIP := tailnet.IPFromUUID(peerID) - _, _, _, err := conn.Ping(testutil.Context(t, testutil.WaitLong), peerIP) - require.NoError(t, err, "ping peer") - }, + RunTests: integration.TestSuite, + }, + { + Name: "EasyNATDERP", + SetupNetworking: integration.SetupNetworkingEasyNAT, + StartServer: integration.StartServerBasic, + StartClient: integration.StartClientBasic, + RunTests: integration.TestSuite, }, } -//nolint:paralleltest +//nolint:paralleltest,tparallel func TestIntegration(t *testing.T) { if *isSubprocess { handleTestSubprocess(t) @@ -87,10 +91,13 @@ func TestIntegration(t *testing.T) { } for _, topo := range topologies { - //nolint:paralleltest + topo := topo t.Run(topo.Name, func(t *testing.T) { - log := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + // These can run in parallel because every test should be in an + // isolated NetNS. + t.Parallel() + log := slogtest.Make(t, nil).Leveled(slog.LevelDebug) networking := topo.SetupNetworking(t, log) // Fork the three child processes. @@ -100,13 +107,13 @@ func TestIntegration(t *testing.T) { client2ErrCh, closeClient2 := startClientSubprocess(t, topo.Name, networking, 2) // Wait for client1 to exit. - require.NoError(t, <-client1ErrCh) + require.NoError(t, <-client1ErrCh, "client 1 exited") // Close client2 and the server. closeClient2() - require.NoError(t, <-client2ErrCh) + require.NoError(t, <-client2ErrCh, "client 2 exited") closeServer() - require.NoError(t, <-serverErrCh) + require.NoError(t, <-serverErrCh, "server exited") }) } } @@ -152,8 +159,14 @@ func handleTestSubprocess(t *testing.T) { conn := topo.StartClient(t, log, serverURL, myID, peerID) if *clientRunTests { + // Wait for connectivity. + peerIP := tailnet.IPFromUUID(peerID) + if !conn.AwaitReachable(testutil.Context(t, testutil.WaitLong), peerIP) { + t.Fatalf("peer %v did not become reachable", peerIP) + } + topo.RunTests(t, log, serverURL, myID, peerID, conn) - // and exit + // then exit return } } @@ -194,7 +207,7 @@ func waitForServerAvailable(t *testing.T, serverURL *url.URL) { } func startServerSubprocess(t *testing.T, topologyName string, networking integration.TestNetworking) (<-chan error, func()) { - return startSubprocess(t, networking.ProcessServer.NetNSFd, []string{ + return startSubprocess(t, "server", networking.ProcessServer.NetNS, []string{ "--subprocess", "--test-name=" + topologyName, "--role=server", @@ -210,10 +223,12 @@ func startClientSubprocess(t *testing.T, topologyName string, networking integra myID = integration.Client1ID peerID = integration.Client2ID accessURL = networking.ServerAccessURLClient1 + netNS = networking.ProcessClient1.NetNS ) if clientNumber == 2 { myID, peerID = peerID, myID accessURL = networking.ServerAccessURLClient2 + netNS = networking.ProcessClient2.NetNS } flags := []string{ @@ -229,14 +244,15 @@ func startClientSubprocess(t *testing.T, topologyName string, networking integra flags = append(flags, "--client-run-tests") } - return startSubprocess(t, networking.ProcessClient1.NetNSFd, flags) + return startSubprocess(t, clientName, netNS, flags) } -func startSubprocess(t *testing.T, netNSFd int, flags []string) (<-chan error, func()) { +func startSubprocess(t *testing.T, processName string, netNS *os.File, flags []string) (<-chan error, func()) { name := os.Args[0] - args := append(os.Args[1:], flags...) + // Always use verbose mode since it gets piped to the parent test anyways. + args := append(os.Args[1:], append([]string{"-test.v=true"}, flags...)...) - if netNSFd > 0 { + if netNS != nil { // We use nsenter to enter the namespace. // We can't use `setns` easily from Golang in the parent process because // you can't execute the syscall in the forked child thread before it @@ -249,11 +265,17 @@ func startSubprocess(t *testing.T, netNSFd int, flags []string) (<-chan error, f } cmd := exec.Command(name, args...) - if netNSFd > 0 { - cmd.ExtraFiles = []*os.File{os.NewFile(uintptr(netNSFd), "")} + if netNS != nil { + cmd.ExtraFiles = []*os.File{netNS} } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + + out := &testWriter{ + name: processName, + t: t, + } + t.Cleanup(out.Flush) + cmd.Stdout = out + cmd.Stderr = out cmd.SysProcAttr = &syscall.SysProcAttr{ Pdeathsig: syscall.SIGTERM, } @@ -293,3 +315,43 @@ func startSubprocess(t *testing.T, netNSFd int, flags []string) (<-chan error, f return waitErr, closeFn } + +type testWriter struct { + mut sync.Mutex + name string + t *testing.T + + capturedLines []string +} + +func (w *testWriter) Write(p []byte) (n int, err error) { + w.mut.Lock() + defer w.mut.Unlock() + str := string(p) + split := strings.Split(str, "\n") + for _, s := range split { + if s == "" { + continue + } + + // If a line begins with "\s*--- (PASS|FAIL)" or is just PASS or FAIL, + // then it's a test result line. We want to capture it and log it later. + trimmed := strings.TrimSpace(s) + if strings.HasPrefix(trimmed, "--- PASS") || strings.HasPrefix(trimmed, "--- FAIL") || trimmed == "PASS" || trimmed == "FAIL" { + w.capturedLines = append(w.capturedLines, s) + continue + } + + w.t.Logf("%s output: \t%s", w.name, s) + } + return len(p), nil +} + +func (w *testWriter) Flush() { + w.mut.Lock() + defer w.mut.Unlock() + for _, s := range w.capturedLines { + w.t.Logf("%s output: \t%s", w.name, s) + } + w.capturedLines = nil +} diff --git a/tailnet/test/integration/network.go b/tailnet/test/integration/network.go index 95d68ca8e7..604d7827cd 100644 --- a/tailnet/test/integration/network.go +++ b/tailnet/test/integration/network.go @@ -4,16 +4,276 @@ package integration import ( + "bytes" "fmt" + "net/url" "os" "os/exec" + "testing" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/tailscale/netlink" "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/cryptorand" + "github.com/coder/coder/v2/tailnet" ) +type TestTopology struct { + Name string + // SetupNetworking creates interfaces and network namespaces for the test. + // The most simple implementation is NetworkSetupDefault, which only creates + // a network namespace shared for all tests. + SetupNetworking func(t *testing.T, logger slog.Logger) TestNetworking + + // StartServer gets called in the server subprocess. It's expected to start + // the coordinator server in the background and return. + StartServer func(t *testing.T, logger slog.Logger, listenAddr string) + // StartClient gets called in each client subprocess. It's expected to + // create the tailnet.Conn and ensure connectivity to it's peer. + StartClient func(t *testing.T, logger slog.Logger, serverURL *url.URL, myID uuid.UUID, peerID uuid.UUID) *tailnet.Conn + + // RunTests is the main test function. It's called in each of the client + // subprocesses. If tests can only run once, they should check the client ID + // and return early if it's not the expected one. + RunTests func(t *testing.T, logger slog.Logger, serverURL *url.URL, myID uuid.UUID, peerID uuid.UUID, conn *tailnet.Conn) +} + +type TestNetworking struct { + // ServerListenAddr is the IP address and port that the server listens on, + // passed to StartServer. + ServerListenAddr string + // ServerAccessURLClient1 is the hostname and port that the first client + // uses to access the server. + ServerAccessURLClient1 string + // ServerAccessURLClient2 is the hostname and port that the second client + // uses to access the server. + ServerAccessURLClient2 string + + // Networking settings for each subprocess. + ProcessServer TestNetworkingProcess + ProcessClient1 TestNetworkingProcess + ProcessClient2 TestNetworkingProcess +} + +type TestNetworkingProcess struct { + // NetNS to enter. If nil, the current network namespace is used. + NetNS *os.File +} + +// SetupNetworkingLoopback creates a network namespace with a loopback interface +// for all tests to share. This is the simplest networking setup. The network +// namespace only exists for isolation on the host and doesn't serve any routing +// purpose. +func SetupNetworkingLoopback(t *testing.T, _ slog.Logger) TestNetworking { + netNSName := "codertest_netns_" + randStr, err := cryptorand.String(4) + require.NoError(t, err, "generate random string for netns name") + netNSName += randStr + + // Create a single network namespace for all tests so we can have an + // isolated loopback interface. + netNSFile := createNetNS(t, netNSName) + + var ( + listenAddr = "127.0.0.1:8080" + process = TestNetworkingProcess{ + NetNS: netNSFile, + } + ) + return TestNetworking{ + ServerListenAddr: listenAddr, + ServerAccessURLClient1: "http://" + listenAddr, + ServerAccessURLClient2: "http://" + listenAddr, + ProcessServer: process, + ProcessClient1: process, + ProcessClient2: process, + } +} + +// SetupNetworkingEasyNAT creates a network namespace with a router that NATs +// packets between two clients and a server. +// See createFakeRouter for the full topology. +// NAT is achieved through a single iptables masquerade rule. +func SetupNetworkingEasyNAT(t *testing.T, _ slog.Logger) TestNetworking { + router := createFakeRouter(t) + + // Set up iptables masquerade rules to allow the router to NAT packets + // between the Three Kingdoms. + _, err := commandInNetNS(router.RouterNetNS, "sysctl", []string{"-w", "net.ipv4.ip_forward=1"}).Output() + require.NoError(t, wrapExitErr(err), "enable IP forwarding in router NetNS") + _, err = commandInNetNS(router.RouterNetNS, "iptables", []string{ + "-t", "nat", + "-A", "POSTROUTING", + // Every interface except loopback. + "!", "-o", "lo", + "-j", "MASQUERADE", + }).Output() + require.NoError(t, wrapExitErr(err), "add iptables masquerade rule") + + return router.Net +} + +type fakeRouter struct { + Net TestNetworking + + RouterNetNS *os.File + RouterVeths struct { + Server string + Client1 string + Client2 string + } + ServerNetNS *os.File + ServerVeth string + Client1NetNS *os.File + Client1Veth string + Client2NetNS *os.File + Client2Veth string +} + +// fakeRouter creates multiple namespaces with veth pairs between them with +// the following topology: +// +// namespaces: +// - router +// - server +// - client1 +// - client2 +// +// veth pairs: +// - router-server (10.0.1.1) <-> server-router (10.0.1.2) +// - router-client1 (10.0.2.1) <-> client1-router (10.0.2.2) +// - router-client2 (10.0.3.1) <-> client2-router (10.0.3.2) +// +// No iptables rules are created, so packets will not be forwarded out of the +// box. Routes are created between all namespaces based on the veth pairs, +// however. +func createFakeRouter(t *testing.T) fakeRouter { + t.Helper() + const ( + routerServerPrefix = "10.0.1." + routerServerIP = routerServerPrefix + "1" + serverIP = routerServerPrefix + "2" + routerClient1Prefix = "10.0.2." + routerClient1IP = routerClient1Prefix + "1" + client1IP = routerClient1Prefix + "2" + routerClient2Prefix = "10.0.3." + routerClient2IP = routerClient2Prefix + "1" + client2IP = routerClient2Prefix + "2" + ) + + prefix := uniqNetName(t) + "_" + router := fakeRouter{} + router.RouterVeths.Server = prefix + "r-s" + router.RouterVeths.Client1 = prefix + "r-c1" + router.RouterVeths.Client2 = prefix + "r-c2" + router.ServerVeth = prefix + "s-r" + router.Client1Veth = prefix + "c1-r" + router.Client2Veth = prefix + "c2-r" + + // Create namespaces. + router.RouterNetNS = createNetNS(t, prefix+"r") + serverNS := createNetNS(t, prefix+"s") + client1NS := createNetNS(t, prefix+"c1") + client2NS := createNetNS(t, prefix+"c2") + + vethPairs := []struct { + parentName string + peerName string + parentNS *os.File + peerNS *os.File + parentIP string + peerIP string + }{ + { + parentName: router.RouterVeths.Server, + peerName: router.ServerVeth, + parentNS: router.RouterNetNS, + peerNS: serverNS, + parentIP: routerServerIP, + peerIP: serverIP, + }, + { + parentName: router.RouterVeths.Client1, + peerName: router.Client1Veth, + parentNS: router.RouterNetNS, + peerNS: client1NS, + parentIP: routerClient1IP, + peerIP: client1IP, + }, + { + parentName: router.RouterVeths.Client2, + peerName: router.Client2Veth, + parentNS: router.RouterNetNS, + peerNS: client2NS, + parentIP: routerClient2IP, + peerIP: client2IP, + }, + } + + for _, vethPair := range vethPairs { + err := createVethPair(vethPair.parentName, vethPair.peerName) + require.NoErrorf(t, err, "create veth pair %q <-> %q", vethPair.parentName, vethPair.peerName) + + // Move the veth interfaces to the respective network namespaces. + err = setVethNetNS(vethPair.parentName, int(vethPair.parentNS.Fd())) + require.NoErrorf(t, err, "set veth %q to NetNS", vethPair.parentName) + err = setVethNetNS(vethPair.peerName, int(vethPair.peerNS.Fd())) + require.NoErrorf(t, err, "set veth %q to NetNS", vethPair.peerName) + + // Set IP addresses on the interfaces. + err = setInterfaceIP(vethPair.parentNS, vethPair.parentName, vethPair.parentIP) + require.NoErrorf(t, err, "set IP %q on interface %q", vethPair.parentIP, vethPair.parentName) + err = setInterfaceIP(vethPair.peerNS, vethPair.peerName, vethPair.peerIP) + require.NoErrorf(t, err, "set IP %q on interface %q", vethPair.peerIP, vethPair.peerName) + + // Bring up both interfaces. + err = setInterfaceUp(vethPair.parentNS, vethPair.parentName) + require.NoErrorf(t, err, "bring up interface %q", vethPair.parentName) + err = setInterfaceUp(vethPair.peerNS, vethPair.peerName) + require.NoErrorf(t, err, "bring up interface %q", vethPair.parentName) + + // We don't need to add a route from parent to peer since the kernel + // already adds a default route for the /24. We DO need to add a default + // route from peer to parent, however. + err = addRouteInNetNS(vethPair.peerNS, []string{"default", "via", vethPair.parentIP, "dev", vethPair.peerName}) + require.NoErrorf(t, err, "add peer default route to %q", vethPair.peerName) + } + + router.Net = TestNetworking{ + ServerListenAddr: serverIP + ":8080", + ServerAccessURLClient1: "http://" + serverIP + ":8080", + ServerAccessURLClient2: "http://" + serverIP + ":8080", + ProcessServer: TestNetworkingProcess{ + NetNS: serverNS, + }, + ProcessClient1: TestNetworkingProcess{ + NetNS: client1NS, + }, + ProcessClient2: TestNetworkingProcess{ + NetNS: client2NS, + }, + } + return router +} + +func uniqNetName(t *testing.T) string { + t.Helper() + netNSName := "cdr_" + randStr, err := cryptorand.String(3) + require.NoError(t, err, "generate random string for netns name") + netNSName += randStr + return netNSName +} + // createNetNS creates a new network namespace with the given name. The returned // file is a file descriptor to the network namespace. -func createNetNS(name string) (*os.File, error) { +// Note: all cleanup is handled for you, you do not need to call Close on the +// returned file. +func createNetNS(t *testing.T, name string) *os.File { // We use ip-netns here because it handles the process of creating a // disowned netns for us. // The only way to create a network namespace is by calling unshare(2) or @@ -23,33 +283,107 @@ func createNetNS(name string) (*os.File, error) { // will keep the namespace alive until the mount is removed. // ip-netns does this for us. Without it, we would have to fork anyways. // Later, we will use nsenter to enter this network namespace. - err := exec.Command("ip", "netns", "add", name).Run() - if err != nil { - return nil, xerrors.Errorf("create network namespace via ip-netns: %w", err) - } + _, err := exec.Command("ip", "netns", "add", name).Output() + require.NoError(t, wrapExitErr(err), "create network namespace via ip-netns") + t.Cleanup(func() { + _, _ = exec.Command("ip", "netns", "delete", name).Output() + }) - // Open /run/netns/$name to get a file descriptor to the network namespace - // so it stays active after we soft-delete it. + // Open /run/netns/$name to get a file descriptor to the network namespace. path := fmt.Sprintf("/run/netns/%s", name) file, err := os.OpenFile(path, os.O_RDONLY, 0) - if err != nil { - return nil, xerrors.Errorf("open network namespace file %q: %w", path, err) - } + require.NoError(t, err, "open network namespace file") + t.Cleanup(func() { + _ = file.Close() + }) // Exec "ip link set lo up" in the namespace to bring up loopback // networking. //nolint:gosec - err = exec.Command("ip", "netns", "exec", name, "ip", "link", "set", "lo", "up").Run() - if err != nil { - return nil, xerrors.Errorf("bring up loopback interface in network namespace: %w", err) - } + _, err = exec.Command("ip", "-netns", name, "link", "set", "lo", "up").Output() + require.NoError(t, wrapExitErr(err), "bring up loopback interface in network namespace") - // Remove the network namespace. The kernel will keep it around until the - // file descriptor is closed. - err = exec.Command("ip", "netns", "delete", name).Run() - if err != nil { - return nil, xerrors.Errorf("soft delete network namespace via ip-netns: %w", err) - } - - return file, nil + return file +} + +// createVethPair creates a veth pair with the given names. +func createVethPair(parentVethName, peerVethName string) error { + vethLinkAttrs := netlink.NewLinkAttrs() + vethLinkAttrs.Name = parentVethName + veth := &netlink.Veth{ + LinkAttrs: vethLinkAttrs, + PeerName: peerVethName, + } + + err := netlink.LinkAdd(veth) + if err != nil { + return xerrors.Errorf("LinkAdd(name: %q, peerName: %q): %w", parentVethName, peerVethName, err) + } + + return nil +} + +// setVethNetNS moves the veth interface to the specified network namespace. +func setVethNetNS(vethName string, netNSFd int) error { + veth, err := netlink.LinkByName(vethName) + if err != nil { + return xerrors.Errorf("LinkByName(%q): %w", vethName, err) + } + + err = netlink.LinkSetNsFd(veth, netNSFd) + if err != nil { + return xerrors.Errorf("LinkSetNsFd(%q, %v): %w", vethName, netNSFd, err) + } + + return nil +} + +// setInterfaceIP sets the IP address on the given interface. It automatically +// adds a /24 subnet mask. +func setInterfaceIP(netNS *os.File, ifaceName, ip string) error { + _, err := commandInNetNS(netNS, "ip", []string{"addr", "add", ip + "/24", "dev", ifaceName}).Output() + if err != nil { + return xerrors.Errorf("set IP %q on interface %q in netns: %w", ip, ifaceName, wrapExitErr(err)) + } + + return nil +} + +// setInterfaceUp brings the given interface up. +func setInterfaceUp(netNS *os.File, ifaceName string) error { + _, err := commandInNetNS(netNS, "ip", []string{"link", "set", ifaceName, "up"}).Output() + if err != nil { + return xerrors.Errorf("bring up interface %q in netns: %w", ifaceName, wrapExitErr(err)) + } + + return nil +} + +// addRouteInNetNS adds a route to the given network namespace. +func addRouteInNetNS(netNS *os.File, route []string) error { + _, err := commandInNetNS(netNS, "ip", append([]string{"route", "add"}, route...)).Output() + if err != nil { + return xerrors.Errorf("add route %q in netns: %w", route, wrapExitErr(err)) + } + + return nil +} + +func commandInNetNS(netNS *os.File, bin string, args []string) *exec.Cmd { + //nolint:gosec + cmd := exec.Command("nsenter", append([]string{"--net=/proc/self/fd/3", bin}, args...)...) + cmd.ExtraFiles = []*os.File{netNS} + return cmd +} + +func wrapExitErr(err error) error { + if err == nil { + return nil + } + + var exitErr *exec.ExitError + if xerrors.As(err, &exitErr) { + return xerrors.Errorf("output: %s\n\n%w", bytes.TrimSpace(exitErr.Stderr), exitErr) + } + return err } diff --git a/tailnet/test/integration/suite.go b/tailnet/test/integration/suite.go new file mode 100644 index 0000000000..54fb0856a2 --- /dev/null +++ b/tailnet/test/integration/suite.go @@ -0,0 +1,31 @@ +//go:build linux +// +build linux + +package integration + +import ( + "net/url" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/testutil" +) + +// TODO: instead of reusing one conn for each suite, maybe we should make a new +// one for each subtest? +func TestSuite(t *testing.T, _ slog.Logger, _ *url.URL, _, peerID uuid.UUID, conn *tailnet.Conn) { + t.Parallel() + + t.Run("Connectivity", func(t *testing.T) { + t.Parallel() + peerIP := tailnet.IPFromUUID(peerID) + _, _, _, err := conn.Ping(testutil.Context(t, testutil.WaitLong), peerIP) + require.NoError(t, err, "ping peer") + }) + + // TODO: more +}