summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorJames O'Doherty <james@theodohertyfamily.com>2026-05-22 09:18:55 -0400
committerJames O'Doherty <james@theodohertyfamily.com>2026-05-22 09:18:55 -0400
commit96d75d9f1fab87365d7e6b5070eed3a5757c3484 (patch)
treee01144dbb5338826d36f1b07444ebd78407c3bf4 /internal
parent756ba94292b408cc4f23d137b2c4c52009b2b38d (diff)
Refactor CLI for testability and implement hermetic config path injection
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/cli.go114
-rw-r--r--internal/cli/cli_test.go34
-rw-r--r--internal/config/config.go18
-rw-r--r--internal/namespace/namespace_test.go14
-rw-r--r--internal/wireguard/wireguard_test.go8
5 files changed, 176 insertions, 12 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
new file mode 100644
index 0000000..cb95202
--- /dev/null
+++ b/internal/cli/cli.go
@@ -0,0 +1,114 @@
+package cli
+
+import (
+ "flag"
+ "fmt"
+
+ "git.theodohertyfamily.com/tools/wg-wrap/internal/config"
+)
+
+type App struct {
+ Args []string
+ ConfigDir string // Optional override for profile storage location
+}
+
+func NewApp(args []string) *App {
+ return &App{Args: args}
+}
+
+func (a *App) Run() error {
+ // Handle subcommands first (profile list, import, configure, delete, stop)
+ if len(a.Args) > 1 && a.Args[1] == "profile" {
+ return a.handleProfileCmd()
+ }
+
+ cfg := &config.Config{}
+
+ fs := flag.NewFlagSet("wg-wrap", flag.ExitOnError)
+ fs.StringVar(&cfg.Profile, "profile", "", "WireGuard profile to use (filename without extension in ~/.config/wg-wrap/profiles/)")
+ fs.StringVar(&cfg.DNSServer, "dns-server", "", "Override DNS server to use")
+
+ args := a.Args[1:]
+ sepIdx := -1
+ for i, arg := range args {
+ if arg == "--" {
+ sepIdx = i
+ break
+ }
+ }
+
+ var flagsToParse []string
+ if sepIdx != -1 {
+ flagsToParse = args[:sepIdx]
+ cfg.Command = args[sepIdx+1:]
+ } else {
+ flagsToParse = args
+ }
+
+ err := fs.Parse(flagsToParse)
+ if err != nil {
+ return fmt.Errorf("error parsing flags: %w", err)
+ }
+
+ if sepIdx == -1 {
+ cfg.Command = fs.Args()
+ }
+
+ if len(cfg.Command) == 0 {
+ return fmt.Errorf("no command provided. use --help for usage")
+ }
+
+ if cfg.Profile == "" {
+ cfg.Profile = "default"
+ }
+
+ profilesDir := a.ConfigDir
+ if profilesDir == "" {
+ profilesDir = config.GetDefaultProfilesDir()
+ }
+
+ fmt.Printf("Profile: %s\n", cfg.Profile)
+ fmt.Printf("Profiles Directory: %s\n", profilesDir)
+ fmt.Printf("DNS Server: %s\n", cfg.DNSServer)
+ fmt.Printf("Command: %v\n", cfg.Command)
+ return nil
+}
+
+func (a *App) handleProfileCmd() error {
+ if len(a.Args) < 3 {
+ return fmt.Errorf("usage: wg-wrap profile <list|import|configure|delete|stop> [args]")
+ }
+
+ subCmd := a.Args[2]
+ switch subCmd {
+ case "list":
+ fmt.Println("Listing profiles...")
+ return fmt.Errorf("profile list not yet implemented")
+ case "import":
+ if len(a.Args) < 4 {
+ return fmt.Errorf("usage: wg-wrap profile import <path>")
+ }
+ fmt.Printf("Importing profile from %s...\n", a.Args[3])
+ return fmt.Errorf("profile import not yet implemented")
+ case "configure":
+ if len(a.Args) < 4 {
+ return fmt.Errorf("usage: wg-wrap profile configure <name>")
+ }
+ fmt.Printf("Configuring profile %s...\n", a.Args[3])
+ return fmt.Errorf("profile configure not yet implemented")
+ case "delete":
+ if len(a.Args) < 4 {
+ return fmt.Errorf("usage: wg-wrap profile delete <name>")
+ }
+ fmt.Printf("Deleting profile %s...\n", a.Args[3])
+ return fmt.Errorf("profile delete not yet implemented")
+ case "stop":
+ if len(a.Args) < 4 {
+ return fmt.Errorf("usage: wg-wrap profile stop <name>")
+ }
+ fmt.Printf("Stopping profile %s and unpinning namespace...\n", a.Args[3])
+ return fmt.Errorf("profile stop not yet implemented")
+ default:
+ return fmt.Errorf("unknown profile subcommand: %s", subCmd)
+ }
+}
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
new file mode 100644
index 0000000..71ff6cb
--- /dev/null
+++ b/internal/cli/cli_test.go
@@ -0,0 +1,34 @@
+package cli
+
+import (
+ "testing"
+)
+
+func TestAppRun_ProfileDirInjection(t *testing.T) {
+ // Set up a temporary directory to simulate XDG_CONFIG_HOME/wg-wrap/profiles
+ tmpDir := t.TempDir()
+
+ tests := []struct {
+ name string
+ args []string
+ wantErr bool
+ }{
+ {
+ name: "valid profile with injected dir",
+ args: []string{"wg-wrap", "--profile", "test-vpn", "curl", "google.com"},
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ app := NewApp(tt.args)
+ app.ConfigDir = tmpDir // Inject temporary directory
+
+ err := app.Run()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("App.Run() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 5aa8462..d81a1f6 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1,7 +1,25 @@
package config
+import (
+ "os"
+ "path/filepath"
+)
+
type Config struct {
Profile string
DNSServer string
Command []string
}
+
+// GetDefaultProfilesDir returns the standard XDG path for wg-wrap profiles.
+func GetDefaultProfilesDir() string {
+ configHome := os.Getenv("XDG_CONFIG_HOME")
+ if configHome == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "etc/wg-wrap/profiles" // Fallback
+ }
+ configHome = filepath.Join(home, ".config")
+ }
+ return filepath.Join(configHome, "wg-wrap", "profiles")
+}
diff --git a/internal/namespace/namespace_test.go b/internal/namespace/namespace_test.go
index cfa0e9b..e39710d 100644
--- a/internal/namespace/namespace_test.go
+++ b/internal/namespace/namespace_test.go
@@ -7,18 +7,16 @@ import (
)
func TestNamespaceCreation(t *testing.T) {
- t.Log("Integration Test: Verifying CLONE_NEWUSER and CLONE_NEWNET syscalls")
- // TODO: Verify that unshare creates a new network namespace
- // TODO: Verify that the process has root privileges inside the namespace
+ // Test that CLONE_NEWUSER and CLONE_NEWNET are called in the correct sequence and a netns is created.
+ t.Skip("not implemented")
}
func TestNamespacePinning(t *testing.T) {
- t.Log("Integration Test: Verifying bind-mount of namespace to /run/user/$UID/wg-wrap/")
- // TODO: Verify that the namespace survives after the process exits
- // TODO: Verify that we can re-join the namespace via setns
+ // Test that the network namespace is bind-mounted to /run/user/$UID/wg-wrap/ and persists after process exit.
+ t.Skip("not implemented")
}
func TestRoutingSetup(t *testing.T) {
- t.Log("Integration Test: Verifying TUN device creation and IP routing table setup")
- // TODO: Mock 'ip' command or use netlink to verify route exists
+ // Test that the TUN device is created and the routing table is configured with the correct VPN subnet.
+ t.Skip("not implemented")
}
diff --git a/internal/wireguard/wireguard_test.go b/internal/wireguard/wireguard_test.go
index 05e0fb7..9bbd24c 100644
--- a/internal/wireguard/wireguard_test.go
+++ b/internal/wireguard/wireguard_test.go
@@ -7,11 +7,11 @@ import (
)
func TestWireGuardDeviceBinding(t *testing.T) {
- t.Log("Integration Test: Verifying binding of userspace WG device to TUN device")
- // TODO: Initialize a wg-go device and link it to a mock TUN
+ // Test that the userspace WireGuard device is correctly bound to the Linux TUN device.
+ t.Skip("not implemented")
}
func TestIpcSetConfiguration(t *testing.T) {
- t.Log("Integration Test: Verifying IpcSet applies keys and endpoints correctly")
- // TODO: Verify that configuration updates are reflected in the device state
+ // Test that IpcSet correctly updates the WireGuard device keys and endpoints.
+ t.Skip("not implemented")
}