diff options
| author | James O'Doherty <james@theodohertyfamily.com> | 2026-05-29 19:56:45 -0400 |
|---|---|---|
| committer | James O'Doherty <james@theodohertyfamily.com> | 2026-05-29 19:56:45 -0400 |
| commit | a7c7fa9e76c9c7015c31378062aa5d0c17b0f38f (patch) | |
| tree | f45c63ab1d8647c657175dd92ec15000dd64975e /tests/e2e/race_test.go | |
| parent | c6a1240e469ff8170cf31b39a01c1cb08fdb86f4 (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.go | 94 |
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) + } +} |
