summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJames O'Doherty <james@theodohertyfamily.com>2026-06-07 22:57:34 -0400
committerJames O'Doherty <james@theodohertyfamily.com>2026-06-07 22:57:34 -0400
commitf8afb7d5889f5c8b6ea256fd078fa8426d21c7be (patch)
treebb0683f4abdd22886ddb0b748114abff5dfef4d1
parent7010768877c227c9410a06908e4cb3e54db403bd (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.md16
-rw-r--r--internal/cli/cli.go122
-rw-r--r--internal/cli/cli_test.go2
-rw-r--r--tests/e2e/config_hotswap_test.go4
-rw-r--r--tests/e2e/config_test.go2
-rw-r--r--tests/e2e/crash_recovery_test.go2
-rw-r--r--tests/e2e/e2e_test.go12
-rw-r--r--tests/e2e/lifecycle_test.go4
-rw-r--r--tests/e2e/network_change_test.go4
-rw-r--r--tests/e2e/resource_exhaustion_test.go2
-rw-r--r--tests/e2e/sharing_test.go4
-rw-r--r--tests/e2e/vulnerability_test.go2
12 files changed, 96 insertions, 80 deletions
diff --git a/README.md b/README.md
index ee4ea6a..c7ef7c7 100644
--- a/README.md
+++ b/README.md
@@ -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),