summaryrefslogtreecommitdiff
path: root/tests/e2e/race_test.go
diff options
context:
space:
mode:
authorJames O'Doherty <james@theodohertyfamily.com>2026-05-29 19:56:45 -0400
committerJames O'Doherty <james@theodohertyfamily.com>2026-05-29 19:56:45 -0400
commita7c7fa9e76c9c7015c31378062aa5d0c17b0f38f (patch)
treef45c63ab1d8647c657175dd92ec15000dd64975e /tests/e2e/race_test.go
parentc6a1240e469ff8170cf31b39a01c1cb08fdb86f4 (diff)
Fix DNS leaks, lifecycle race, and editor arg splitting
- DNS Leak / Isolation Bypass: Blocked glibc's systemd-resolved and D-Bus socket communication within the unprivileged mount namespace by introducing BlockHostServices(). This targeted mount-blocking forces glibc to fall back to the standard resolv.conf DNS routing path and prevents host leaks. - Lifecycle Race: Reordered and protected the reference-counting cleanup routine under the profile flock to ensure that check-and-unpin operations are atomic and do not teardown namespaces actively used by parallel processes. - Editor Arguments: Split the EDITOR environment variable into discrete field tokens before invocation to support editor configurations containing command-line flags. - Testing: Added E2E regression tests for DNS leak detection, namespace unpinning concurrency, and editor argument parsing. All E2E tests now compile and pass cleanly.
Diffstat (limited to 'tests/e2e/race_test.go')
-rw-r--r--tests/e2e/race_test.go94
1 files changed, 94 insertions, 0 deletions
diff --git a/tests/e2e/race_test.go b/tests/e2e/race_test.go
new file mode 100644
index 0000000..3f5ecfe
--- /dev/null
+++ b/tests/e2e/race_test.go
@@ -0,0 +1,94 @@
+package e2e
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+)
+
+// TestLifecycleRace proves that a new process joining an existing namespace
+// can have that namespace unpinned if an exiting process incorrectly
+// thinks it's the last one out.
+func TestLifecycleRace(t *testing.T) {
+ binaryPath, err := GetBinaryPath()
+ if err != nil {
+ t.Skipf("Skipping test: %v", err)
+ }
+
+ tmpRuntimeDir := t.TempDir()
+ profile := "race-test"
+ pidsDir := filepath.Join(tmpRuntimeDir, "profiles", profile, "pids")
+
+ // Setup a valid profile config to ensure tunneling starts
+ tmpConfigDir := t.TempDir()
+ 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)
+ }
+
+ // We use a a long-running sleep for Process A to keep the namespace active.
+ // Process A is the "Victim".
+ cmdA := exec.Command(binaryPath, "--profile", profile, "--", "sleep", "10")
+ cmdA.Env = append(os.Environ(),
+ fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir),
+ fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir),
+ )
+ 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 establish the namespace and register PID
+ waitForPids(t, pidsDir, 1)
+
+ // Process B is the "Saboteur". It will join and then exit.
+ // We will loop this to increase the chance of hitting the race window.
+ for i := 0; i < 5; i++ {
+ cmdB := exec.Command(binaryPath, "--profile", profile, "--", "sleep", "0.1")
+ cmdB.Env = append(os.Environ(),
+ fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir),
+ fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir),
+ )
+ if err := cmdB.Start(); err != nil {
+ t.Fatalf("Failed to start Process B (iteration %d): %v", i, err)
+ }
+
+ // Wait for Process B to register
+ waitForPids(t, pidsDir, 2)
+
+ // Let Process B exit. The defer block in ExecuteCommand will:
+ // 1. Unregister Process B
+ // 2. Check IsLastProcess() -> might return true if Process A's PID is stale or miscounted
+ // 3. UnpinNamespace()
+ if err := cmdB.Wait(); err != nil {
+ t.Fatalf("Process B failed: %v", err)
+ }
+
+ // After B exits, Process A should still be the only remaining process.
+ // We check if the namespace pin file was accidentally deleted by B.
+ nsPath := filepath.Join(tmpRuntimeDir, "profiles", profile+".ns")
+ if _, err := os.Stat(nsPath); os.IsNotExist(err) {
+ t.Errorf("BUG: Namespace pin file was deleted by exiting Process B, despite Process A still running!")
+ return
+ }
+
+ // Wait for the PID count to drop back to 1
+ waitForPids(t, pidsDir, 1)
+ }
+}