diff options
| author | James O'Doherty <james@theodohertyfamily.com> | 2026-06-04 22:57:35 -0400 |
|---|---|---|
| committer | James O'Doherty <james@theodohertyfamily.com> | 2026-06-04 22:57:35 -0400 |
| commit | 04dca5dada8c2d971ff3b54eeedc5ab6e53a29ac (patch) | |
| tree | a9890073a0eb21bc7db3aef2fcbe66cdc2fc9ceb | |
| parent | 66b782e261f1cd928ad6a8482788a65fb484db45 (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.go | 2 | ||||
| -rw-r--r-- | internal/manager/manager.go | 68 | ||||
| -rw-r--r-- | internal/namespace/namespace.go | 18 | ||||
| -rw-r--r-- | internal/namespace/ops.go | 80 | ||||
| -rw-r--r-- | internal/paths/paths_test.go | 156 | ||||
| -rw-r--r-- | internal/wireguard/wireguard.go | 12 | ||||
| -rw-r--r-- | tests/e2e/arg_integrity_test.go | 5 | ||||
| -rw-r--r-- | tests/e2e/config_hotswap_test.go | 5 | ||||
| -rw-r--r-- | tests/e2e/config_test.go | 5 | ||||
| -rw-r--r-- | tests/e2e/crash_recovery_test.go | 5 | ||||
| -rw-r--r-- | tests/e2e/e2e_test.go | 37 | ||||
| -rw-r--r-- | tests/e2e/fuzz_args_test.go | 5 | ||||
| -rw-r--r-- | tests/e2e/lifecycle_test.go | 5 | ||||
| -rw-r--r-- | tests/e2e/mount_leak_test.go | 5 | ||||
| -rw-r--r-- | tests/e2e/network_change_test.go | 5 | ||||
| -rw-r--r-- | tests/e2e/resource_exhaustion_test.go | 5 | ||||
| -rw-r--r-- | tests/e2e/sharing_test.go | 5 | ||||
| -rw-r--r-- | tests/e2e/test_helpers.go | 11 | ||||
| -rw-r--r-- | tests/e2e/vulnerability_test.go | 7 |
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) } |
