summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJames O'Doherty <james@theodohertyfamily.com>2026-06-04 22:57:35 -0400
committerJames O'Doherty <james@theodohertyfamily.com>2026-06-04 22:57:35 -0400
commit04dca5dada8c2d971ff3b54eeedc5ab6e53a29ac (patch)
treea9890073a0eb21bc7db3aef2fcbe66cdc2fc9ceb
parent66b782e261f1cd928ad6a8482788a65fb484db45 (diff)
refactor: decouple namespace operations and improve test coverage
- Introduce `namespace.Ops` interface to decouple `Manager` from system-level namespace operations, enabling easier unit testing via mocks. - Add unit tests for `internal/paths` to verify path resolution logic across different environment configurations. - Implement `EnsureBinary` helper in E2E tests to gracefully skip tests when `WG_WRAP_BIN` is not set, allowing `go test ./...` to pass in non-build environments. - Apply project-wide formatting and fix linting issues.
-rw-r--r--internal/cli/cli.go2
-rw-r--r--internal/manager/manager.go68
-rw-r--r--internal/namespace/namespace.go18
-rw-r--r--internal/namespace/ops.go80
-rw-r--r--internal/paths/paths_test.go156
-rw-r--r--internal/wireguard/wireguard.go12
-rw-r--r--tests/e2e/arg_integrity_test.go5
-rw-r--r--tests/e2e/config_hotswap_test.go5
-rw-r--r--tests/e2e/config_test.go5
-rw-r--r--tests/e2e/crash_recovery_test.go5
-rw-r--r--tests/e2e/e2e_test.go37
-rw-r--r--tests/e2e/fuzz_args_test.go5
-rw-r--r--tests/e2e/lifecycle_test.go5
-rw-r--r--tests/e2e/mount_leak_test.go5
-rw-r--r--tests/e2e/network_change_test.go5
-rw-r--r--tests/e2e/resource_exhaustion_test.go5
-rw-r--r--tests/e2e/sharing_test.go5
-rw-r--r--tests/e2e/test_helpers.go11
-rw-r--r--tests/e2e/vulnerability_test.go7
19 files changed, 317 insertions, 124 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 1873e28..9041670 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -32,7 +32,7 @@ func NewApp(args []string) *App {
func (a *App) getManager() *manager.Manager {
if a.mgr == nil {
- a.mgr = manager.New(a.getPathManager())
+ a.mgr = manager.New(a.getPathManager(), namespace.NewLinuxOps())
}
return a.mgr
}
diff --git a/internal/manager/manager.go b/internal/manager/manager.go
index 99a1a32..ffb02c0 100644
--- a/internal/manager/manager.go
+++ b/internal/manager/manager.go
@@ -9,11 +9,11 @@
// To support multiple concurrent commands on the same WireGuard tunnel without re-establishing
// connections, wg-wrap employs session-based persistent, unprivileged namespaces.
//
-// 1. Tracking: Process usage is tracked using active PID files inside the runtime base directory.
-// 2. Ref-Counting & Cleanup: Active PIDs are regularly pruned. When the last active process exits,
-// the namespace is unpinned and resources are reclaimed.
-// 3. Setns Join: When a new process is executed on an active profile, it discovers an active PID
-// and attaches itself to the existing User, Mount, and Network namespaces of the active tunnel.
+// 1. Tracking: Process usage is tracked using active PID files inside the runtime base directory.
+// 2. Ref-Counting & Cleanup: Active PIDs are regularly pruned. When the last active process exits,
+// the namespace is unpinned and resources are reclaimed.
+// 3. Setns Join: When a new process is executed on an active profile, it discovers an active PID
+// and attaches itself to the existing User, Mount, and Network namespaces of the active tunnel.
package manager
import (
@@ -30,22 +30,22 @@ import (
"git.theodohertyfamily.com/wg-wrap/pkg/wgconf"
)
-// Manager orchestrates the high-level lifecycle of WireGuard tunnels
-// and their associated network namespaces.
type Manager struct {
// PM is the path manager used to resolve configuration and runtime directories.
PM *paths.PathManager
+ // NS handles the network namespace operations.
+ NS namespace.Ops
}
-// New creates a new Manager with the given path manager.
-func New(pm *paths.PathManager) *Manager {
- return &Manager{PM: pm}
+// New creates a new Manager with the given path manager and namespace operations.
+func New(pm *paths.PathManager, ns namespace.Ops) *Manager {
+ return &Manager{PM: pm, NS: ns}
}
// Bootstrap ensures the process is running in an isolated user and network namespace.
// If an active session already exists for the profile, it joins it.
func (m *Manager) Bootstrap(cfg *config.Config) error {
- if namespace.IsIsolated() {
+ if m.NS.IsIsolated() {
return nil
}
@@ -53,28 +53,28 @@ func (m *Manager) Bootstrap(cfg *config.Config) error {
_ = os.Setenv("WG_WRAP_HOST_RUNTIME_BASE_DIR", m.PM.RuntimeBaseDir())
// Acquire startup lock to prevent concurrent bootstrap/joining races.
- lockFile, lockErr := namespace.AcquireProfileLock(m.PM, cfg.Profile)
+ lockFile, lockErr := m.NS.AcquireProfileLock(m.PM, cfg.Profile)
if lockErr == nil {
- defer namespace.ReleaseProfileLock(lockFile)
+ defer m.NS.ReleaseProfileLock(lockFile)
}
// Before bootstrapping, see if an active namespace/process for the profile exists.
- activePid, err := namespace.FindActiveProfilePid(m.PM, cfg.Profile)
+ activePid, err := m.NS.FindActiveProfilePid(m.PM, cfg.Profile)
if err == nil && activePid > 0 {
// Release the lock before executing the command to allow others to join.
- namespace.ReleaseProfileLock(lockFile)
+ m.NS.ReleaseProfileLock(lockFile)
// Register this PID before joining to prevent the race where the joining process
// hasn't registered itself yet, causing the existing process to think it's the last one.
- _ = namespace.RegisterProcess(m.PM, cfg.Profile)
+ _ = m.NS.RegisterProcess(m.PM, cfg.Profile)
- if err := namespace.BootstrapJoin(activePid); err != nil {
+ if err := m.NS.BootstrapJoin(activePid); err != nil {
return fmt.Errorf("failed to join existing namespace: %w", err)
}
return nil
}
- if err := namespace.Bootstrap(); err != nil {
+ if err := m.NS.Bootstrap(); err != nil {
return fmt.Errorf("bootstrap failed: %w", err)
}
@@ -84,25 +84,25 @@ func (m *Manager) Bootstrap(cfg *config.Config) error {
// Execute manages the full execution lifecycle inside an isolated namespace:
// lock acquisition, PID registration, tunnel initialization, command execution, and cleanup.
func (m *Manager) Execute(cfg *config.Config, verbose bool) error {
- if !namespace.IsIsolated() {
+ if !m.NS.IsIsolated() {
return fmt.Errorf("Execute called without namespace isolation")
}
// Acquire execution lock during configuration and startup inside the namespace.
- lockFile, lockErr := namespace.AcquireProfileLock(m.PM, cfg.Profile)
+ lockFile, lockErr := m.NS.AcquireProfileLock(m.PM, cfg.Profile)
var lockFileReleased bool
if lockErr == nil {
defer func() {
if !lockFileReleased {
- namespace.ReleaseProfileLock(lockFile)
+ m.NS.ReleaseProfileLock(lockFile)
}
}()
}
- if err := namespace.PruneStalePids(m.PM, cfg.Profile); err != nil {
+ if err := m.NS.PruneStalePids(m.PM, cfg.Profile); err != nil {
fmt.Fprintf(os.Stderr, "failed to prune stale pids: %v\n", err)
}
- if err := namespace.RegisterProcess(m.PM, cfg.Profile); err != nil {
+ if err := m.NS.RegisterProcess(m.PM, cfg.Profile); err != nil {
return fmt.Errorf("failed to register process: %w", err)
}
@@ -113,31 +113,31 @@ func (m *Manager) Execute(cfg *config.Config, verbose bool) error {
if lockErr == nil && !lockFileReleased {
cleanupLock = lockFile
} else {
- cleanupLock, cleanupErr = namespace.AcquireProfileLock(m.PM, cfg.Profile)
+ cleanupLock, cleanupErr = m.NS.AcquireProfileLock(m.PM, cfg.Profile)
}
if cleanupErr == nil {
- if err := namespace.UnregisterProcess(m.PM, cfg.Profile); err != nil {
+ if err := m.NS.UnregisterProcess(m.PM, cfg.Profile); err != nil {
fmt.Fprintf(os.Stderr, "failed to unregister process: %v\n", err)
}
- if err := namespace.PruneStalePids(m.PM, cfg.Profile); err != nil {
+ if err := m.NS.PruneStalePids(m.PM, cfg.Profile); err != nil {
fmt.Fprintf(os.Stderr, "failed to prune stale pids during cleanup: %v\n", err)
}
- last, lastErr := namespace.IsLastProcess(m.PM, cfg.Profile)
+ last, lastErr := m.NS.IsLastProcess(m.PM, cfg.Profile)
if lastErr == nil && last {
- if err := namespace.UnpinNamespace(m.PM, cfg.Profile); err != nil {
+ if err := m.NS.UnpinNamespace(m.PM, cfg.Profile); err != nil {
fmt.Fprintf(os.Stderr, "failed to unpin namespace: %v\n", err)
}
}
if lockErr == nil && !lockFileReleased {
lockFileReleased = true
}
- namespace.ReleaseProfileLock(cleanupLock)
+ m.NS.ReleaseProfileLock(cleanupLock)
} else {
- if err := namespace.UnregisterProcess(m.PM, cfg.Profile); err != nil {
+ if err := m.NS.UnregisterProcess(m.PM, cfg.Profile); err != nil {
fmt.Fprintf(os.Stderr, "failed to unregister process: %v\n", err)
}
}
@@ -191,7 +191,7 @@ func (m *Manager) Execute(cfg *config.Config, verbose bool) error {
}
defer tunnel.Close()
- if err := namespace.PinNamespace(m.PM, cfg.Profile); err != nil {
+ if err := m.NS.PinNamespace(m.PM, cfg.Profile); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to pin namespace: %v\n", err)
}
} else {
@@ -203,7 +203,7 @@ func (m *Manager) Execute(cfg *config.Config, verbose bool) error {
}
lockFileReleased = true
- namespace.ReleaseProfileLock(lockFile)
+ m.NS.ReleaseProfileLock(lockFile)
cmd := exec.Command(cfg.Command[0], cfg.Command[1:]...)
cmd.Stdin = os.Stdin
@@ -220,7 +220,7 @@ func (m *Manager) Execute(cfg *config.Config, verbose bool) error {
// StopProfile stops a profile session by unpinning its namespace.
func (m *Manager) StopProfile(profile string) error {
- if err := namespace.UnpinNamespace(m.PM, profile); err != nil {
+ if err := m.NS.UnpinNamespace(m.PM, profile); err != nil {
return fmt.Errorf("failed to stop profile: %w", err)
}
return nil
@@ -228,7 +228,7 @@ func (m *Manager) StopProfile(profile string) error {
// VerifyLifecycle checks for an active session for the given profile.
func (m *Manager) VerifyLifecycle(profile string) error {
- activePid, err := namespace.FindActiveProfilePid(m.PM, profile)
+ activePid, err := m.NS.FindActiveProfilePid(m.PM, profile)
if err != nil || activePid <= 0 {
return fmt.Errorf("no active session found for profile %s", profile)
}
diff --git a/internal/namespace/namespace.go b/internal/namespace/namespace.go
index b05dea2..a50f70a 100644
--- a/internal/namespace/namespace.go
+++ b/internal/namespace/namespace.go
@@ -6,15 +6,15 @@
// scheduler, and to maintain encrypted UDP socket connectivity over the host's network,
// wg-wrap employs an advanced bootstrap loop:
//
-// 1. Host-Bound Socket Creation: During the initial host-level start, a UDP socket is opened
-// on 0.0.0.0:0 on the host, and its FD is stored in the environment (WG_WRAP_HOST_SOCKET_FD).
-// 2. Helper Deployment: An embedded single-threaded C launcher is used to bridge the transition.
-// 3. Namespace Transition: The process replaces itself with the C launcher via syscall.Exec.
-// 4. Isolation: The launcher performs the unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWNET)
-// sequence to isolate Mount, User, and Network environments.
-// 5. Re-entry: The launcher then execvp's the original wg-wrap binary.
-// 6. FDBind Tunnel Initialization: The second instance of wg-wrap wraps the host socket FD
-// inside a custom FDBind struct to initialize wireguard-go.
+// 1. Host-Bound Socket Creation: During the initial host-level start, a UDP socket is opened
+// on 0.0.0.0:0 on the host, and its FD is stored in the environment (WG_WRAP_HOST_SOCKET_FD).
+// 2. Helper Deployment: An embedded single-threaded C launcher is used to bridge the transition.
+// 3. Namespace Transition: The process replaces itself with the C launcher via syscall.Exec.
+// 4. Isolation: The launcher performs the unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWNET)
+// sequence to isolate Mount, User, and Network environments.
+// 5. Re-entry: The launcher then execvp's the original wg-wrap binary.
+// 6. FDBind Tunnel Initialization: The second instance of wg-wrap wraps the host socket FD
+// inside a custom FDBind struct to initialize wireguard-go.
//
// User Namespace Sequence:
// To create a network namespace without root, wg-wrap follows the sequence:
diff --git a/internal/namespace/ops.go b/internal/namespace/ops.go
new file mode 100644
index 0000000..b2b5e10
--- /dev/null
+++ b/internal/namespace/ops.go
@@ -0,0 +1,80 @@
+package namespace
+
+import (
+ "os"
+
+ "git.theodohertyfamily.com/wg-wrap/internal/paths"
+)
+
+// Ops defines the set of operations required by the Manager to handle
+// namespace isolation, lifecycle, and synchronization.
+type Ops interface {
+ IsIsolated() bool
+ Bootstrap() error
+ BootstrapJoin(pid int) error
+ RegisterProcess(pm *paths.PathManager, profile string) error
+ UnregisterProcess(pm *paths.PathManager, profile string) error
+ PruneStalePids(pm *paths.PathManager, profile string) error
+ IsLastProcess(pm *paths.PathManager, profile string) (bool, error)
+ PinNamespace(pm *paths.PathManager, profile string) error
+ UnpinNamespace(pm *paths.PathManager, profile string) error
+ FindActiveProfilePid(pm *paths.PathManager, profile string) (int, error)
+ AcquireProfileLock(pm *paths.PathManager, profile string) (*os.File, error)
+ ReleaseProfileLock(file *os.File)
+}
+
+// linuxOps is the concrete implementation of Ops for Linux systems.
+type linuxOps struct{}
+
+// NewLinuxOps returns a new instance of the Linux-specific namespace operations.
+func NewLinuxOps() Ops {
+ return &linuxOps{}
+}
+
+func (l *linuxOps) IsIsolated() bool {
+ return IsIsolated()
+}
+
+func (l *linuxOps) Bootstrap() error {
+ return Bootstrap()
+}
+
+func (l *linuxOps) BootstrapJoin(pid int) error {
+ return BootstrapJoin(pid)
+}
+
+func (l *linuxOps) RegisterProcess(pm *paths.PathManager, profile string) error {
+ return RegisterProcess(pm, profile)
+}
+
+func (l *linuxOps) UnregisterProcess(pm *paths.PathManager, profile string) error {
+ return UnregisterProcess(pm, profile)
+}
+
+func (l *linuxOps) PruneStalePids(pm *paths.PathManager, profile string) error {
+ return PruneStalePids(pm, profile)
+}
+
+func (l *linuxOps) IsLastProcess(pm *paths.PathManager, profile string) (bool, error) {
+ return IsLastProcess(pm, profile)
+}
+
+func (l *linuxOps) PinNamespace(pm *paths.PathManager, profile string) error {
+ return PinNamespace(pm, profile)
+}
+
+func (l *linuxOps) UnpinNamespace(pm *paths.PathManager, profile string) error {
+ return UnpinNamespace(pm, profile)
+}
+
+func (l *linuxOps) FindActiveProfilePid(pm *paths.PathManager, profile string) (int, error) {
+ return FindActiveProfilePid(pm, profile)
+}
+
+func (l *linuxOps) AcquireProfileLock(pm *paths.PathManager, profile string) (*os.File, error) {
+ return AcquireProfileLock(pm, profile)
+}
+
+func (l *linuxOps) ReleaseProfileLock(file *os.File) {
+ ReleaseProfileLock(file)
+}
diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go
new file mode 100644
index 0000000..caf103d
--- /dev/null
+++ b/internal/paths/paths_test.go
@@ -0,0 +1,156 @@
+package paths
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestPathManager_ConfigDir(t *testing.T) {
+ tests := []struct {
+ name string
+ configOverride string
+ envConfigHome string
+ userHome string
+ expected string
+ }{
+ {
+ name: "Use override",
+ configOverride: "/tmp/custom-config",
+ expected: "/tmp/custom-config",
+ },
+ {
+ name: "Use XDG_CONFIG_HOME",
+ envConfigHome: "/tmp/xdg-config",
+ expected: "/tmp/xdg-config/wg-wrap/profiles",
+ },
+ {
+ name: "Fallback to home",
+ userHome: "/home/testuser",
+ expected: "/home/testuser/.config/wg-wrap/profiles",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Backup and restore env
+ oldXdg := os.Getenv("XDG_CONFIG_HOME")
+ defer func() {
+ if err := os.Setenv("XDG_CONFIG_HOME", oldXdg); err != nil {
+ t.Errorf("failed to restore XDG_CONFIG_HOME: %v", err)
+ }
+ }()
+
+ if tt.envConfigHome != "" {
+ if err := os.Setenv("XDG_CONFIG_HOME", tt.envConfigHome); err != nil {
+ t.Fatalf("failed to set XDG_CONFIG_HOME: %v", err)
+ }
+ } else {
+ if err := os.Unsetenv("XDG_CONFIG_HOME"); err != nil {
+ t.Fatalf("failed to unset XDG_CONFIG_HOME: %v", err)
+ }
+ }
+
+ // We can't easily mock os.UserHomeDir() without monkey patching or interfaces
+ // but we can test the other paths.
+ pm := NewPathManager(tt.configOverride, "")
+ got := pm.ConfigDir()
+
+ if tt.userHome == "" && got != tt.expected {
+ t.Errorf("ConfigDir() = %v, want %v", got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestPathManager_RuntimeBaseDir(t *testing.T) {
+ tests := []struct {
+ name string
+ runtimeOverride string
+ envHostRuntime string
+ envXdgRuntime string
+ expected string
+ }{
+ {
+ name: "Use override",
+ runtimeOverride: "/tmp/custom-runtime",
+ expected: "/tmp/custom-runtime",
+ },
+ {
+ name: "Use WG_WRAP_HOST_RUNTIME_BASE_DIR",
+ envHostRuntime: "/tmp/host-runtime",
+ expected: "/tmp/host-runtime",
+ },
+ {
+ name: "Use XDG_RUNTIME_DIR",
+ envXdgRuntime: "/tmp/xdg-runtime",
+ expected: "/tmp/xdg-runtime",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Backup and restore env
+ oldHost := os.Getenv("WG_WRAP_HOST_RUNTIME_BASE_DIR")
+ defer func() {
+ if err := os.Setenv("WG_WRAP_HOST_RUNTIME_BASE_DIR", oldHost); err != nil {
+ t.Errorf("failed to restore WG_WRAP_HOST_RUNTIME_BASE_DIR: %v", err)
+ }
+ }()
+ oldXdg := os.Getenv("XDG_RUNTIME_DIR")
+ defer func() {
+ if err := os.Setenv("XDG_RUNTIME_DIR", oldXdg); err != nil {
+ t.Errorf("failed to restore XDG_RUNTIME_DIR: %v", err)
+ }
+ }()
+
+ if tt.envHostRuntime != "" {
+ if err := os.Setenv("WG_WRAP_HOST_RUNTIME_BASE_DIR", tt.envHostRuntime); err != nil {
+ t.Fatalf("failed to set WG_WRAP_HOST_RUNTIME_BASE_DIR: %v", err)
+ }
+ } else {
+ if err := os.Unsetenv("WG_WRAP_HOST_RUNTIME_BASE_DIR"); err != nil {
+ t.Fatalf("failed to unset WG_WRAP_HOST_RUNTIME_BASE_DIR: %v", err)
+ }
+ }
+
+ if tt.envXdgRuntime != "" {
+ if err := os.Setenv("XDG_RUNTIME_DIR", tt.envXdgRuntime); err != nil {
+ t.Fatalf("failed to set XDG_RUNTIME_DIR: %v", err)
+ }
+ } else {
+ if err := os.Unsetenv("XDG_RUNTIME_DIR"); err != nil {
+ t.Fatalf("failed to unset XDG_RUNTIME_DIR: %v", err)
+ }
+ }
+
+ pm := NewPathManager("", tt.runtimeOverride)
+ got := pm.RuntimeBaseDir()
+
+ if got != tt.expected {
+ t.Errorf("RuntimeBaseDir() = %v, want %v", got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestPathManager_ProfilePaths(t *testing.T) {
+ pm := NewPathManager("", "/tmp/runtime")
+ profile := "test-profile"
+
+ t.Run("ProfileNamespacePath", func(t *testing.T) {
+ expected := filepath.Join("/tmp/runtime", "profiles", profile+".ns")
+ got := pm.ProfileNamespacePath(profile)
+ if got != expected {
+ t.Errorf("ProfileNamespacePath() = %v, want %v", got, expected)
+ }
+ })
+
+ t.Run("ProfilePidsDir", func(t *testing.T) {
+ expected := filepath.Join("/tmp/runtime", "profiles", profile, "pids")
+ got := pm.ProfilePidsDir(profile)
+ if got != expected {
+ t.Errorf("ProfilePidsDir() = %v, want %v", got, expected)
+ }
+ })
+}
diff --git a/internal/wireguard/wireguard.go b/internal/wireguard/wireguard.go
index cea9590..5db588e 100644
--- a/internal/wireguard/wireguard.go
+++ b/internal/wireguard/wireguard.go
@@ -3,12 +3,12 @@
// Package wireguard provides the userspace WireGuard implementation and TUN device binding.
//
// Data Flow:
-// 1. Egress: A process sends a packet. The Linux kernel routes it via tun0. The userspace
-// WireGuard device reads this packet, encrypts it, and sends it as a UDP packet to the
-// remote endpoint via the preserved host socket.
-// 2. Ingress: A UDP packet arrives via the host socket. The userspace WireGuard device
-// decrypts it and writes the raw IP packet back into the TUN device, delivering it to
-// the process.
+// 1. Egress: A process sends a packet. The Linux kernel routes it via tun0. The userspace
+// WireGuard device reads this packet, encrypts it, and sends it as a UDP packet to the
+// remote endpoint via the preserved host socket.
+// 2. Ingress: A UDP packet arrives via the host socket. The userspace WireGuard device
+// decrypts it and writes the raw IP packet back into the TUN device, delivering it to
+// the process.
//
// MTU Management:
// WireGuard adds overhead. To prevent fragmentation and packet loss, the TUN device
diff --git a/tests/e2e/arg_integrity_test.go b/tests/e2e/arg_integrity_test.go
index 497a51d..daace7e 100644
--- a/tests/e2e/arg_integrity_test.go
+++ b/tests/e2e/arg_integrity_test.go
@@ -20,10 +20,7 @@ func TestArgumentIntegrity(t *testing.T) {
for _, payload := range payloads {
t.Run(fmt.Sprintf("Payload_%s", payload), func(t *testing.T) {
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skip("Skipping E2E test: wg-wrap binary not found (WG_WRAP_BIN not set)")
- }
+ binaryPath := EnsureBinary(t)
cmd := exec.Command(binaryPath, "test-args", payload)
out, err := cmd.CombinedOutput()
if err != nil {
diff --git a/tests/e2e/config_hotswap_test.go b/tests/e2e/config_hotswap_test.go
index a962b97..54155a0 100644
--- a/tests/e2e/config_hotswap_test.go
+++ b/tests/e2e/config_hotswap_test.go
@@ -13,10 +13,7 @@ import (
// does not affect an active session. A process joining an existing session
// should use the established tunnel's state, not the updated file.
func TestConfigHotSwap(t *testing.T) {
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
tmpRuntimeDir := t.TempDir()
tmpConfigDir := t.TempDir()
diff --git a/tests/e2e/config_test.go b/tests/e2e/config_test.go
index c8749ce..33eb6e6 100644
--- a/tests/e2e/config_test.go
+++ b/tests/e2e/config_test.go
@@ -10,10 +10,7 @@ import (
)
func TestConfigPropagation(t *testing.T) {
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
tmpConfigDir := t.TempDir()
tmpRuntimeDir := t.TempDir()
diff --git a/tests/e2e/crash_recovery_test.go b/tests/e2e/crash_recovery_test.go
index 669f25f..11d6ca3 100644
--- a/tests/e2e/crash_recovery_test.go
+++ b/tests/e2e/crash_recovery_test.go
@@ -12,10 +12,7 @@ import (
// TestCrashRecovery verifies that wg-wrap can recover from a "dirty" state
// where a previous run crashed, leaving behind stale PID and pin files.
func TestCrashRecovery(t *testing.T) {
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
tmpRuntimeDir := t.TempDir()
tmpConfigDir := t.TempDir()
diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go
index 939d231..907bb22 100644
--- a/tests/e2e/e2e_test.go
+++ b/tests/e2e/e2e_test.go
@@ -13,10 +13,7 @@ import (
func TestDataPlaneConnectivity(t *testing.T) {
// 1. Determine binary path
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
// 2. Setup isolated config & runtime folders for testing
tmpDir := t.TempDir()
@@ -78,10 +75,7 @@ AllowedIPs = 10.0.0.0/24
}
}()
- err = cmd.Run()
- // We expect the command (ping) to fail because our mock peer does not complete
- // the handshake or reply to pings, but we log the error for diagnostic purposes.
- if err != nil {
+ if err := cmd.Run(); err != nil {
t.Logf("wg-wrap command returned error (expected since mock peer doesn't reply): %v", err)
}
@@ -100,10 +94,7 @@ AllowedIPs = 10.0.0.0/24
}
func TestNetworkIsolation(t *testing.T) {
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
cmd := exec.Command(binaryPath, "test-ns")
out, err := cmd.CombinedOutput()
@@ -117,10 +108,7 @@ func TestNetworkIsolation(t *testing.T) {
}
func TestDNSIsolation(t *testing.T) {
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
// 1. Start Mock DNS Server
dnsServer, port := StartMockDNSServer(t)
@@ -190,10 +178,7 @@ AllowedIPs = 10.0.0.0/24
}
func TestDNSPrecedence(t *testing.T) {
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
tmpDir := t.TempDir()
profileName := "test-dns-precedence"
@@ -276,10 +261,7 @@ AllowedIPs = 10.0.0.0/24
}
func TestMTUFragmentation(t *testing.T) {
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
cmd := exec.Command(binaryPath, "--profile", "default", "--", "true")
if err := cmd.Run(); err != nil {
@@ -288,14 +270,11 @@ func TestMTUFragmentation(t *testing.T) {
}
func TestExitCodePropagation(t *testing.T) {
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
// Run a command that exits with code 42
cmd := exec.Command(binaryPath, "--profile", "default", "--", "sh", "-c", "exit 42")
- err = cmd.Run()
+ err := cmd.Run()
if err == nil {
t.Fatalf("expected command to fail with exit status 42, but it succeeded")
}
diff --git a/tests/e2e/fuzz_args_test.go b/tests/e2e/fuzz_args_test.go
index 0d71e0e..1007f32 100644
--- a/tests/e2e/fuzz_args_test.go
+++ b/tests/e2e/fuzz_args_test.go
@@ -15,10 +15,7 @@ func FuzzArgumentIntegrity(f *testing.F) {
f.Add("\x00null\x00")
f.Fuzz(func(t *testing.T, payload string) {
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skip("Skipping E2E fuzz test: wg-wrap binary not found (WG_WRAP_BIN not set)")
- }
+ binaryPath := EnsureBinary(t)
out, err := exec.Command(binaryPath, "test-args", payload).CombinedOutput()
diff --git a/tests/e2e/lifecycle_test.go b/tests/e2e/lifecycle_test.go
index ffd731f..d0d7271 100644
--- a/tests/e2e/lifecycle_test.go
+++ b/tests/e2e/lifecycle_test.go
@@ -62,10 +62,7 @@ func waitForLifecycle(t *testing.T, binaryPath, runtimeDir, profile string, expe
func TestNamespaceLifecycleAutomation(t *testing.T) {
// 1. Setup Environment
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
// 2. Override the runtime base dir to a temporary location
tmpRuntimeDir := t.TempDir()
diff --git a/tests/e2e/mount_leak_test.go b/tests/e2e/mount_leak_test.go
index 428675f..e3fe071 100644
--- a/tests/e2e/mount_leak_test.go
+++ b/tests/e2e/mount_leak_test.go
@@ -13,10 +13,7 @@ import (
// TestDNSMountLeak verifies that /etc/resolv.conf bind mounts are cleaned up
// after a profile is stopped.
func TestDNSMountLeak(t *testing.T) {
- bin, err := GetBinaryPath()
- if err != nil {
- t.Fatal(err)
- }
+ bin := EnsureBinary(t)
profile := "leak-test"
dnsServer := "8.8.8.8"
diff --git a/tests/e2e/network_change_test.go b/tests/e2e/network_change_test.go
index fb75e02..f1ca215 100644
--- a/tests/e2e/network_change_test.go
+++ b/tests/e2e/network_change_test.go
@@ -14,10 +14,7 @@ import (
// Since we can't easily toggle physical hardware in CI, we verify that the
// userspace WireGuard engine can handle connectivity interruptions.
func TestHostNetworkChange(t *testing.T) {
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
tmpRuntimeDir := t.TempDir()
tmpConfigDir := t.TempDir()
diff --git a/tests/e2e/resource_exhaustion_test.go b/tests/e2e/resource_exhaustion_test.go
index 3e60cdb..b5cdaf9 100644
--- a/tests/e2e/resource_exhaustion_test.go
+++ b/tests/e2e/resource_exhaustion_test.go
@@ -11,10 +11,7 @@ import (
// TestResourceExhaustion ensures that repeatedly starting and stopping
// tunnels does not leak mounts, file descriptors, or namespaces.
func TestResourceExhaustion(t *testing.T) {
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
tmpRuntimeDir := t.TempDir()
tmpConfigDir := t.TempDir()
diff --git a/tests/e2e/sharing_test.go b/tests/e2e/sharing_test.go
index b0971f9..1ecfbe6 100644
--- a/tests/e2e/sharing_test.go
+++ b/tests/e2e/sharing_test.go
@@ -11,10 +11,7 @@ import (
)
func TestNamespaceSharing(t *testing.T) {
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
tmpRuntimeDir := t.TempDir()
tmpConfigDir := t.TempDir()
diff --git a/tests/e2e/test_helpers.go b/tests/e2e/test_helpers.go
index 6d65011..93e851c 100644
--- a/tests/e2e/test_helpers.go
+++ b/tests/e2e/test_helpers.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strings"
+ "testing"
)
// GetBinaryPath resolves the path to the wg-wrap binary.
@@ -21,6 +22,16 @@ func GetBinaryPath() (string, error) {
return path, nil
}
+// EnsureBinary returns the path to the wg-wrap binary or skips the test if it's not available.
+func EnsureBinary(t *testing.T) string {
+ t.Helper()
+ bin, err := GetBinaryPath()
+ if err != nil {
+ t.Skipf("skipping test: %v", err)
+ }
+ return bin
+}
+
// SetEnvOverrides returns a new slice of environment variables with the provided overrides applied.
// It ensures that overriding an existing variable replaces it rather than appending it.
func SetEnvOverrides(overrides map[string]string) []string {
diff --git a/tests/e2e/vulnerability_test.go b/tests/e2e/vulnerability_test.go
index 8de38a3..26536fe 100644
--- a/tests/e2e/vulnerability_test.go
+++ b/tests/e2e/vulnerability_test.go
@@ -56,10 +56,7 @@ func TestDNSLeak(t *testing.T) {
t.Skip("Skipping DNS leak test in short mode")
}
- binaryPath, err := GetBinaryPath()
- if err != nil {
- t.Skipf("Skipping test: %v", err)
- }
+ binaryPath := EnsureBinary(t)
// 1. Setup a profile with a fake, unreachable DNS server.
tmpConfigDir := t.TempDir()
@@ -83,7 +80,7 @@ PublicKey = 0000000000000000000000000000000000000000000000000000000000000000
AllowedIPs = 0.0.0.0/0
Endpoint = 1.1.1.1:51820
`
- err = os.WriteFile(profilePath, []byte(conf), 0644)
+ err := os.WriteFile(profilePath, []byte(conf), 0644)
if err != nil {
t.Fatal(err)
}