From 4ddd0d2ffc7073f2d55ffb6777e3a168af0051f0 Mon Sep 17 00:00:00 2001 From: James O'Doherty Date: Fri, 29 May 2026 20:11:07 -0400 Subject: Refactor rootless namespace joining to use C launcher Fix an architectural shortfall where concurrent sessions failed to share the target network and mount namespaces. Because the Go runtime is multi-threaded, calling unix.Setns with CLONE_NEWNS from Go always returned EINVAL, silently forcing concurrent runs to fall back to bootstrapping separate isolated namespaces and separate WireGuard connections. This commit resolves the issue by extending the embedded single-threaded C launcher to handle namespace joining, and introducing a host-to-isolated path propagation pattern: 1. Launcher setns Support: The C launcher now checks for WG_WRAP_JOIN_PID in the environment. If present, it joins the User, Mount, and Network namespaces of the active PID in single-threaded mode before executing the Go binary. 2. BootstrapJoin Integration: Implemented namespace.BootstrapJoin to transition joining sessions via the launcher. 3. Path Preservation: Export WG_WRAP_HOST_RUNTIME_BASE_DIR from the host to ensure the isolated instance maps the profile and PID directories to the exact same location. 4. Redundant Tunnel Bypass: Detect joined sessions via WG_WRAP_JOINED=1 in the CLI and bypass starting a duplicate WireGuard tunnel on the occupied tun0. 5. Testing: Added tests/e2e/sharing_test.go to assert namespace ID equality, which now passes successfully. 6. Git Tracking: Fixed .gitignore overmatch to stop ignoring cmd/wg-wrap/. --- tests/e2e/sharing_test.go | 108 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/e2e/sharing_test.go (limited to 'tests/e2e') diff --git a/tests/e2e/sharing_test.go b/tests/e2e/sharing_test.go new file mode 100644 index 0000000..b0971f9 --- /dev/null +++ b/tests/e2e/sharing_test.go @@ -0,0 +1,108 @@ +package e2e + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestNamespaceSharing(t *testing.T) { + binaryPath, err := GetBinaryPath() + if err != nil { + t.Skipf("Skipping test: %v", err) + } + + tmpRuntimeDir := t.TempDir() + tmpConfigDir := t.TempDir() + profile := "sharing-test" + + // Write a valid dummy profile so it doesn't run in bare isolation + profilesDir := filepath.Join(tmpConfigDir, "wg-wrap", "profiles") + if err := os.MkdirAll(profilesDir, 0755); err != nil { + t.Fatal(err) + } + profileConfPath := filepath.Join(profilesDir, profile+".conf") + conf := `[Interface] +Address = 10.0.0.2/24 +PrivateKey = 0000000000000000000000000000000000000000000000000000000000000000 +DNS = 1.1.1.1 + +[Peer] +PublicKey = 0000000000000000000000000000000000000000000000000000000000000000 +AllowedIPs = 0.0.0.0/0 +Endpoint = 1.1.1.1:51820 +` + if err := os.WriteFile(profileConfPath, []byte(conf), 0644); err != nil { + t.Fatal(err) + } + + pidsDir := filepath.Join(tmpRuntimeDir, "profiles", profile, "pids") + + // Start Process A running a command that outputs its netns and sleeps + cmdA := exec.Command(binaryPath, "--profile", profile, "--", "sh", "-c", "readlink /proc/self/ns/net && sleep 5") + cmdA.Env = append(os.Environ(), + fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), + fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), + ) + + outA, err := cmdA.StdoutPipe() + if err != nil { + t.Fatalf("Failed to create stdout pipe for Process A: %v", err) + } + + if err := cmdA.Start(); err != nil { + t.Fatalf("Failed to start Process A: %v", err) + } + defer func() { _ = cmdA.Process.Kill() }() + + // Wait for Process A to output its netns ID line by line + var parsedNetnsA string + scannerA := bufio.NewScanner(outA) + for scannerA.Scan() { + line := strings.TrimSpace(scannerA.Text()) + if strings.HasPrefix(line, "net:[") { + parsedNetnsA = line + break + } + } + + if parsedNetnsA == "" { + t.Fatalf("Failed to get netns ID from Process A") + } + + // Wait for Process A's PID to be registered on the host + waitForPids(t, pidsDir, 1) + + // Start Process B to check its netns ID + cmdB := exec.Command(binaryPath, "--profile", profile, "--", "readlink", "/proc/self/ns/net") + cmdB.Env = append(os.Environ(), + fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), + fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), + ) + + outB, err := cmdB.CombinedOutput() + if err != nil { + t.Fatalf("Process B failed to execute: %v\nOutput: %s", err, string(outB)) + } + + var parsedNetnsB string + for _, line := range strings.Split(string(outB), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "net:[") { + parsedNetnsB = trimmed + break + } + } + + if parsedNetnsB == "" { + t.Fatalf("Invalid netns ID format from Process B: %q", string(outB)) + } + + if parsedNetnsA != parsedNetnsB { + t.Errorf("BUG: Process A and Process B do not share the same network namespace!\nProcess A: %s\nProcess B: %s", parsedNetnsA, parsedNetnsB) + } +} -- cgit v1.2.3