mirror of https://github.com/coder/coder.git
chore: add EasyNATDERP tailnet integration test
This commit is contained in:
parent
ed0ca76b0b
commit
d5f5b188fd
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue