diff options
| author | James O'Doherty <james@theodohertyfamily.com> | 2026-06-07 22:57:34 -0400 |
|---|---|---|
| committer | James O'Doherty <james@theodohertyfamily.com> | 2026-06-07 22:57:34 -0400 |
| commit | f8afb7d5889f5c8b6ea256fd078fa8426d21c7be (patch) | |
| tree | bb0683f4abdd22886ddb0b748114abff5dfef4d1 | |
| parent | 7010768877c227c9410a06908e4cb3e54db403bd (diff) | |
feat(cli): introduce explicit run/exec subcommands to prevent typo-execution
Prevent the ambiguity where a mistyped subcommand was interpreted as the target
wrapped process.
- Introduce `run` and `exec` (alias) subcommands for launching wrapped processes.
- Promote internal test commands (`test-ns`, `test-args`, `test-lifecycle`) to explicit subcommands.
- Update CLI routing to return an error for unknown subcommands instead of falling back to the default execution path.
- Update `README.md` usage examples and all test suites to use the new subcommand structure.
| -rw-r--r-- | README.md | 16 | ||||
| -rw-r--r-- | internal/cli/cli.go | 122 | ||||
| -rw-r--r-- | internal/cli/cli_test.go | 2 | ||||
| -rw-r--r-- | tests/e2e/config_hotswap_test.go | 4 | ||||
| -rw-r--r-- | tests/e2e/config_test.go | 2 | ||||
| -rw-r--r-- | tests/e2e/crash_recovery_test.go | 2 | ||||
| -rw-r--r-- | tests/e2e/e2e_test.go | 12 | ||||
| -rw-r--r-- | tests/e2e/lifecycle_test.go | 4 | ||||
| -rw-r--r-- | tests/e2e/network_change_test.go | 4 | ||||
| -rw-r--r-- | tests/e2e/resource_exhaustion_test.go | 2 | ||||
| -rw-r--r-- | tests/e2e/sharing_test.go | 4 | ||||
| -rw-r--r-- | tests/e2e/vulnerability_test.go | 2 |
12 files changed, 96 insertions, 80 deletions
@@ -17,11 +17,12 @@ Import your WireGuard `.conf` file as a profile: ``` ### 3. Run an Application -Run any command wrapped in the VPN: +Run any command wrapped in the VPN using the `run` subcommand: ```bash -./wg-wrap --profile home-vpn -- curl https://ifconfig.me +./wg-wrap run --profile home-vpn -- curl https://ifconfig.me ``` *Only the `curl` command is routed through the VPN; your browser, SSH sessions, and other apps remain on your local network.* +*Only the `curl` command is routed through the VPN; your browser, SSH sessions, and other apps remain on your local network.* --- @@ -39,6 +40,7 @@ Manage your VPN configurations easily from the CLI: | Command | Description | | :--- | :--- | +| `run [options] -- <cmd>` | Run a command in the wrapped environment. | | `profile list` | List all available VPN profiles. | | `profile import <path> [name]` | Import a `.conf` file as a new profile. | | `profile configure <name>` | Edit a profile's configuration in your default editor. | @@ -46,7 +48,7 @@ Manage your VPN configurations easily from the CLI: | `profile stop <name>` | Force-stop an active tunnel session. | ### Diagnostics -Check your environment and verify isolation: +Check your environment and verify isolation using these subcommands: - `show-config`: View resolved paths and current isolation status. - `test-ns`: Verify that you are correctly isolated in a network namespace. - `test-args`: (For developers) Verify 8-bit clean argument passing. @@ -57,20 +59,20 @@ Check your environment and verify isolation: **Run Firefox on a specific VPN:** ```bash -./wg-wrap --profile privacy-vpn -- firefox +./wg-wrap run --profile privacy-vpn -- firefox ``` **Run a series of tests against a private VPC:** ```bash -./wg-wrap --profile dev-vpc -- pytest tests/integration +./wg-wrap run --profile dev-vpc -- pytest tests/integration ``` **Connect to a home server and a work server simultaneously:** ```bash # Terminal 1 -./wg-wrap --profile home-vpn -- ssh home-nas +./wg-wrap run --profile home-vpn -- ssh home-nas # Terminal 2 -./wg-wrap --profile work-vpn -- ssh work-server +./wg-wrap run --profile work-vpn -- ssh work-server ``` --- diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d100d4f..5beb989 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -51,62 +51,40 @@ func (a *App) Route() error { } } - if len(a.Args) > 1 { - switch a.Args[1] { - case "show-config": - return a.showConfig() - case "profile": - return a.handleProfileCmd() - } + if len(a.Args) < 2 { + a.printUsage() + return fmt.Errorf("no command provided") + } + + switch a.Args[1] { + case "show-config": + return a.showConfig() + case "profile": + return a.handleProfileCmd() + case "run", "exec": + return a.executeWrapped(a.Args[2:]) + case "test-ns": + return a.testNS() + case "test-args": + return a.testArgs() + case "test-lifecycle": + return a.testLifecycle() + default: + a.printUsage() + return fmt.Errorf("unknown command: %s", a.Args[1]) } - - return a.Run() } -// Run executes the main logic of wg-wrap, including bootstrapping the namespace +// executeWrapped executes the main logic of wg-wrap, including bootstrapping the namespace // and launching the wrapped command. -func (a *App) Run() error { - if len(a.Args) > 1 { - switch a.Args[1] { - case "test-ns": - if !namespace.IsIsolated() { - if err := namespace.Bootstrap(); err != nil { - return fmt.Errorf("bootstrap failed: %w", err) - } - } - ok, msg := namespace.VerifyIsolation() - if !ok { - return fmt.Errorf("isolation check failed: %s", msg) - } - fmt.Println("Isolation Verified: OK") - return nil - case "test-args": - if !namespace.IsIsolated() { - if err := namespace.Bootstrap(); err != nil { - return fmt.Errorf("bootstrap failed: %w", err) - } - } - return namespace.VerifyArguments(a.Args) - case "test-lifecycle": - profile := "default" - for i := 0; i < len(a.Args)-1; i++ { - if a.Args[i] == "--profile" && i+1 < len(a.Args) { - profile = a.Args[i+1] - break - } - } - return a.getManager().VerifyLifecycle(profile) - } - } - +func (a *App) executeWrapped(args []string) error { cfg := &config.Config{} - fs := flag.NewFlagSet("wg-wrap", flag.ExitOnError) + fs := flag.NewFlagSet("wg-wrap exec", flag.ExitOnError) fs.Usage = a.printUsage fs.StringVar(&cfg.Profile, "profile", "", "WireGuard profile to use") fs.StringVar(&cfg.DNSServer, "dns-server", "", "Override DNS server to use") - args := a.Args[1:] sepIdx := -1 for i, arg := range args { if arg == "--" { @@ -155,20 +133,56 @@ func (a *App) Run() error { return nil } +func (a *App) testNS() error { + if !namespace.IsIsolated() { + if err := namespace.Bootstrap(); err != nil { + return fmt.Errorf("bootstrap failed: %w", err) + } + } + ok, msg := namespace.VerifyIsolation() + if !ok { + return fmt.Errorf("isolation check failed: %s", msg) + } + fmt.Println("Isolation Verified: OK") + return nil +} + +func (a *App) testArgs() error { + if !namespace.IsIsolated() { + if err := namespace.Bootstrap(); err != nil { + return fmt.Errorf("bootstrap failed: %w", err) + } + } + return namespace.VerifyArguments(a.Args) +} + +func (a *App) testLifecycle() error { + profile := "default" + for i := 0; i < len(a.Args)-1; i++ { + if a.Args[i] == "--profile" && i+1 < len(a.Args) { + profile = a.Args[i+1] + break + } + } + return a.getManager().VerifyLifecycle(profile) +} + func (a *App) isVerbose() bool { return os.Getenv("WG_WRAP_VERBOSE") == "1" } func (a *App) printUsage() { - fmt.Fprintf(os.Stderr, "Usage: wg-wrap [options] [-- command [args]]\n\n") - fmt.Fprintf(os.Stderr, "Options:\n") - fmt.Fprintf(os.Stderr, " -profile string\n\tWireGuard profile to use (default \"default\")\n") - fmt.Fprintf(os.Stderr, " -dns-server string\n\tOverride DNS server to use\n\n") + fmt.Fprintf(os.Stderr, "Usage: wg-wrap <command> [args]\n\n") fmt.Fprintf(os.Stderr, "Commands:\n") - fmt.Fprintf(os.Stderr, " show-config\n\tDisplay the current configuration and environment details\n") - fmt.Fprintf(os.Stderr, " profile <command>\n\tManage WireGuard profiles\n\t\t(list, import, configure, delete, stop)\n\n") - fmt.Fprintf(os.Stderr, "Run the wrapped command:\n") - fmt.Fprintf(os.Stderr, " wg-wrap [options] -- <command> [args]\n") + fmt.Fprintf(os.Stderr, " run [options] [-- command] \tRun a command in the wrapped environment\n") + fmt.Fprintf(os.Stderr, " exec [options] [-- command] \tAlias for 'run'\n") + fmt.Fprintf(os.Stderr, " profile <command> \t\tManage WireGuard profiles (list, import, configure, delete, stop)\n") + fmt.Fprintf(os.Stderr, " show-config \t\t\tDisplay the current configuration and environment details\n\n") + fmt.Fprintf(os.Stderr, "Run Options:\n") + fmt.Fprintf(os.Stderr, " -profile string \t\tWireGuard profile to use (default \"default\")\n") + fmt.Fprintf(os.Stderr, " -dns-server string \tOverride DNS server to use\n\n") + fmt.Fprintf(os.Stderr, "Internal/Test Commands:\n") + fmt.Fprintf(os.Stderr, " test-ns, test-args, test-lifecycle\n") } func (a *App) printProfileUsage() { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 093bac3..ca19a36 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -46,7 +46,7 @@ AllowedIPs = 10.0.0.0/24 }{ { name: "valid profile with injected dir", - args: []string{"--profile", "test-vpn", "true"}, + args: []string{"run", "--profile", "test-vpn", "true"}, wantErr: false, }, } diff --git a/tests/e2e/config_hotswap_test.go b/tests/e2e/config_hotswap_test.go index 54155a0..09d9cb1 100644 --- a/tests/e2e/config_hotswap_test.go +++ b/tests/e2e/config_hotswap_test.go @@ -39,7 +39,7 @@ Endpoint = 1.1.1.1:51820 } // Start a process to establish the session - cmdA := exec.Command(binaryPath, "--profile", profile, "--", "sleep", "1.0") + cmdA := exec.Command(binaryPath, "run", "--profile", profile, "--", "sleep", "1.0") cmdA.Env = append(os.Environ(), fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), @@ -68,7 +68,7 @@ Endpoint = 8.8.8.8:51820 // 3. Launch a second process. It should join the existing session // regardless of the fact that the .conf file has changed. - cmdB := exec.Command(binaryPath, "--profile", profile, "--", "ls") + cmdB := exec.Command(binaryPath, "run", "--profile", profile, "--", "ls") cmdB.Env = append(os.Environ(), fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), diff --git a/tests/e2e/config_test.go b/tests/e2e/config_test.go index 33eb6e6..28857c8 100644 --- a/tests/e2e/config_test.go +++ b/tests/e2e/config_test.go @@ -53,7 +53,7 @@ AllowedIPs = 10.0.0.0/24 } // Test 2: Configuration after bootstrap (Isolated) - cmdIsolated := exec.Command(binaryPath, "--profile", profile, "--", "sh", "-c", "echo $XDG_RUNTIME_DIR") + cmdIsolated := exec.Command(binaryPath, "run", "--profile", profile, "--", "sh", "-c", "echo $XDG_RUNTIME_DIR") cmdIsolated.Env = append(os.Environ(), fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), diff --git a/tests/e2e/crash_recovery_test.go b/tests/e2e/crash_recovery_test.go index 11d6ca3..ea29025 100644 --- a/tests/e2e/crash_recovery_test.go +++ b/tests/e2e/crash_recovery_test.go @@ -60,7 +60,7 @@ Endpoint = 1.1.1.1:51820 // 2. Try to run wg-wrap. // It should see the stale PID, prune it, realize the namespace is actually dead, // and start a fresh tunnel. - cmd := exec.Command(binaryPath, "--profile", profile, "--", "ls") + cmd := exec.Command(binaryPath, "run", "--profile", profile, "--", "ls") cmd.Env = append(os.Environ(), fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 907bb22..98711e4 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -57,7 +57,7 @@ AllowedIPs = 10.0.0.0/24 } // 3. Launch wg-wrap with a command that triggers traffic - cmd := exec.Command(binaryPath, "--profile", profile, "--", "ping", "-c", "1", "-W", "1", "10.0.0.1") + cmd := exec.Command(binaryPath, "run", "--profile", profile, "--", "ping", "-c", "1", "-W", "1", "10.0.0.1") cmd.Env = append(os.Environ(), fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpDir), fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpDir), @@ -138,7 +138,7 @@ AllowedIPs = 10.0.0.0/24 // 3. Test /etc/resolv.conf modification expectedDNS := "1.1.1.1" - cmd := exec.Command(binaryPath, "--profile", profile, "--dns-server", expectedDNS, "--", "cat", "/etc/resolv.conf") + cmd := exec.Command(binaryPath, "run", "--profile", profile, "--dns-server", expectedDNS, "--", "cat", "/etc/resolv.conf") cmd.Env = append(os.Environ(), fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpDir), fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpDir), @@ -154,7 +154,7 @@ AllowedIPs = 10.0.0.0/24 } // 4. Test Data Path: Send a ping to trigger Handshake on the mock server - cmdQuery := exec.Command(binaryPath, "--profile", profile, "--", "ping", "-c", "1", "-W", "1", dnsServerIP) + cmdQuery := exec.Command(binaryPath, "run", "--profile", profile, "--", "ping", "-c", "1", "-W", "1", dnsServerIP) cmdQuery.Env = cmd.Env packetReceived := make(chan bool, 1) @@ -236,7 +236,7 @@ AllowedIPs = 10.0.0.0/24 _ = os.WriteFile(profilePath, []byte(confContent), 0644) // Prepare command args - args := []string{"--profile", profileName} + args := []string{"run", "--profile", profileName} if tt.cliDNS != "" { args = append(args, "--dns-server", tt.cliDNS) } @@ -263,7 +263,7 @@ AllowedIPs = 10.0.0.0/24 func TestMTUFragmentation(t *testing.T) { binaryPath := EnsureBinary(t) - cmd := exec.Command(binaryPath, "--profile", "default", "--", "true") + cmd := exec.Command(binaryPath, "run", "--profile", "default", "--", "true") if err := cmd.Run(); err != nil { t.Errorf("expected command to pass, got: %v", err) } @@ -273,7 +273,7 @@ func TestExitCodePropagation(t *testing.T) { binaryPath := EnsureBinary(t) // Run a command that exits with code 42 - cmd := exec.Command(binaryPath, "--profile", "default", "--", "sh", "-c", "exit 42") + cmd := exec.Command(binaryPath, "run", "--profile", "default", "--", "sh", "-c", "exit 42") err := cmd.Run() if err == nil { t.Fatalf("expected command to fail with exit status 42, but it succeeded") diff --git a/tests/e2e/lifecycle_test.go b/tests/e2e/lifecycle_test.go index d0d7271..8158593 100644 --- a/tests/e2e/lifecycle_test.go +++ b/tests/e2e/lifecycle_test.go @@ -76,7 +76,7 @@ func TestNamespaceLifecycleAutomation(t *testing.T) { t.Run("ReferenceCounting", func(t *testing.T) { // Start a process that exits quickly - cmd1 := exec.Command(binaryPath, "--profile", "default", "--", "sleep", "1.0") + cmd1 := exec.Command(binaryPath, "run", "--profile", "default", "--", "sleep", "1.0") cmd1.Env = SetEnvOverrides(map[string]string{"XDG_RUNTIME_DIR": tmpRuntimeDir}) cmd1.Stdout = os.Stdout cmd1.Stderr = os.Stderr @@ -88,7 +88,7 @@ func TestNamespaceLifecycleAutomation(t *testing.T) { waitForLifecycle(t, binaryPath, tmpRuntimeDir, "default", true) // Start a second process using the same profile with a longer sleep - cmd2 := exec.Command(binaryPath, "--profile", "default", "--", "sleep", "5.0") + cmd2 := exec.Command(binaryPath, "run", "--profile", "default", "--", "sleep", "5.0") cmd2.Env = SetEnvOverrides(map[string]string{"XDG_RUNTIME_DIR": tmpRuntimeDir}) cmd2.Stdout = os.Stdout cmd2.Stderr = os.Stderr diff --git a/tests/e2e/network_change_test.go b/tests/e2e/network_change_test.go index f1ca215..98614b7 100644 --- a/tests/e2e/network_change_test.go +++ b/tests/e2e/network_change_test.go @@ -38,7 +38,7 @@ Endpoint = 1.1.1.1:51820 } // Launch a long-running command to keep the tunnel alive - cmd := exec.Command(binaryPath, "--profile", profile, "--", "sleep", "5") + cmd := exec.Command(binaryPath, "run", "--profile", profile, "--", "sleep", "5") cmd.Env = append(os.Environ(), fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), @@ -57,7 +57,7 @@ Endpoint = 1.1.1.1:51820 // operational and hasn't crashed due to the host socket's nature. // We launch a second process to verify the session is still valid. - cmdJoin := exec.Command(binaryPath, "--profile", profile, "--", "ls") + cmdJoin := exec.Command(binaryPath, "run", "--profile", profile, "--", "ls") cmdJoin.Env = append(os.Environ(), fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), diff --git a/tests/e2e/resource_exhaustion_test.go b/tests/e2e/resource_exhaustion_test.go index b5cdaf9..3c8d202 100644 --- a/tests/e2e/resource_exhaustion_test.go +++ b/tests/e2e/resource_exhaustion_test.go @@ -37,7 +37,7 @@ Endpoint = 1.1.1.1:51820 // We run a burst of short-lived commands to stress the lock and cleanup logic. iterations := 50 for i := 0; i < iterations; i++ { - cmd := exec.Command(binaryPath, "--profile", profile, "--", "true") + cmd := exec.Command(binaryPath, "run", "--profile", profile, "--", "true") cmd.Env = append(os.Environ(), fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), diff --git a/tests/e2e/sharing_test.go b/tests/e2e/sharing_test.go index 1ecfbe6..f6c8476 100644 --- a/tests/e2e/sharing_test.go +++ b/tests/e2e/sharing_test.go @@ -40,7 +40,7 @@ Endpoint = 1.1.1.1:51820 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 := exec.Command(binaryPath, "run", "--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), @@ -75,7 +75,7 @@ Endpoint = 1.1.1.1:51820 waitForPids(t, pidsDir, 1) // Start Process B to check its netns ID - cmdB := exec.Command(binaryPath, "--profile", profile, "--", "readlink", "/proc/self/ns/net") + cmdB := exec.Command(binaryPath, "run", "--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), diff --git a/tests/e2e/vulnerability_test.go b/tests/e2e/vulnerability_test.go index 26536fe..23de6bd 100644 --- a/tests/e2e/vulnerability_test.go +++ b/tests/e2e/vulnerability_test.go @@ -87,7 +87,7 @@ Endpoint = 1.1.1.1:51820 // 2. Run a command that performs a DNS lookup using exec.Command on the wg-wrap binary. // We use 'timeout 3 getent hosts google.com' to ensure it fails quickly instead of waiting on timeouts. - cmd := exec.Command(binaryPath, "--profile", profileName, "--", "timeout", "3", "getent", "hosts", "google.com") + cmd := exec.Command(binaryPath, "run", "--profile", profileName, "--", "timeout", "3", "getent", "hosts", "google.com") cmd.Env = append(os.Environ(), fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), |
